Compare commits

Author SHA1 Message Date
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
61 changed files with 3794 additions and 963 deletions
+77 -15
View File
@@ -8,9 +8,9 @@ on:
workflow_dispatch:
inputs:
bump_type:
description: "Specify the type of version bump"
description: 'Specify the type of version bump'
required: true
default: "patch"
default: 'patch'
type: choice
options:
- patch
@@ -46,7 +46,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
python-version: '3.10'
- name: Install Commitizen
run: |
@@ -108,17 +108,19 @@ jobs:
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 user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
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
git add -u -- Cargo.toml Cargo.lock
git commit -m "chore: bump Cargo.toml to $VERSION"
if ! git diff --quiet -- Cargo.toml Cargo.lock assets/sbx-kit/spec.yaml; then
git add -u -- Cargo.toml Cargo.lock assets/sbx-kit/spec.yaml
git commit -m "chore: bump Cargo.toml and sandbox image to $VERSION"
else
echo "No changes to commit (already at $VERSION)"
fi
@@ -163,28 +165,28 @@ jobs:
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: ""
cargo-flags: ''
- target: aarch64-apple-darwin
os: macos-latest
use-cross: true
cargo-flags: ""
cargo-flags: ''
- target: aarch64-pc-windows-msvc
os: windows-latest
use-cross: true
cargo-flags: ""
cargo-flags: ''
- target: x86_64-apple-darwin
os: macos-latest
cargo-flags: ""
cargo-flags: ''
- target: x86_64-pc-windows-msvc
os: windows-latest
cargo-flags: ""
cargo-flags: ''
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: ""
cargo-flags: ''
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
cargo-flags: ""
cargo-flags: ''
steps:
- name: Check if actor is repository owner
@@ -338,7 +340,7 @@ jobs:
${{ steps.package.outputs.archive }}
${{ steps.package.outputs.sha }}
tag_name: v${{ env.RELEASE_VERSION }}
name: "v${{ env.RELEASE_VERSION }}"
name: 'v${{ env.RELEASE_VERSION }}'
body_path: artifacts/changelog.md
prerelease: false
@@ -456,3 +458,63 @@ jobs:
if: env.ACT != 'true'
with:
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)
+70
View File
@@ -0,0 +1,70 @@
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 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)
![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)
![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 &
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.
* [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]`.
* [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.
* [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.
@@ -60,7 +62,7 @@ Coyote requires the following tools to be installed on your system:
* [uv](https://docs.astral.sh/uv/getting-started/installation/)
* `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)
* **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`
* [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`
@@ -100,6 +102,32 @@ To upgrade `coyote` using Homebrew:
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
#### 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
+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
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
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:
```yaml
+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
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
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:
```yaml
+9 -1
View File
@@ -10,5 +10,13 @@ set -e
main() {
# 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:-.}"
if [[ ! -d "$search_path" ]]; then
echo "Error: directory not found: $search_path" >> "$LLM_OUTPUT"
return 1
echo "Error: directory not found: $search_path" >&2
exit 1
fi
local results
+2 -2
View File
@@ -21,8 +21,8 @@ main() {
local include_filter="${argc_include:-}"
if [[ ! -e "$search_path" ]]; then
echo "Error: path not found: $search_path" >> "$LLM_OUTPUT"
return 1
echo "Error: path not found: $search_path" >&2
exit 1
fi
local grep_args=(-nH --color=never)
+14 -1
View File
@@ -9,5 +9,18 @@ set -e
main() {
# 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.
# @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 --limit The maximum number of lines to read (default: 2000)
# @option --offset <INT> The line number to start reading from (1-indexed, default: 1)
# @option --limit <INT> The maximum number of lines to read (default: 2000)
# @env LLM_OUTPUT=/dev/stdout The output path
@@ -23,8 +23,8 @@ main() {
local limit="${argc_limit:-2000}"
if [[ ! -e "$target" ]]; then
echo "Error: path not found: $target" >> "$LLM_OUTPUT"
return 1
echo "Error: path not found: $target" >&2
exit 1
fi
if [[ -d "$target" ]]; then
@@ -33,9 +33,20 @@ main() {
fi
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)
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
{
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 | {
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
line="${line:0:$MAX_LINE_LENGTH}... (truncated)"
fi
+5 -1
View File
@@ -552,7 +552,7 @@ patch_file() {
continue
}
if (line ~ /^@@ /) {
if (line ~ /^@@/) {
mode = "hunk"
hunkIndex++
patchLineIndex++
@@ -585,6 +585,10 @@ patch_file() {
if (hunkIndex == 0) {
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
}
+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
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
- 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
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
point for all analysis, style questions, etc.
Should the root directory contain a `COYOTE.md` (or `AGENTS.md`/`CLAUDE.md`) file, this contains human-curated project
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
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/.coyote_password testing:/home/agent/
# sbx run testing --kit ./sbx-kit/
schemaVersion: "1"
schemaVersion: '1'
kind: sandbox
name: coyote
displayName: Coyote
@@ -14,10 +14,10 @@ description: >
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
sandbox:
image: "docker/sandbox-templates:shell-docker"
image: 'darkalex17/coyote:v0.7.4'
aiFilename: COYOTE.md
entrypoint:
run: ["bash", "-lc", "exec /home/agent/.cargo/bin/coyote"]
run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
network:
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
@@ -50,96 +50,96 @@ network:
serviceAuth:
openai:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
anthropic:
headerName: x-api-key
valueFormat: "%s"
valueFormat: '%s'
gemini:
headerName: x-goog-api-key
valueFormat: "%s"
valueFormat: '%s'
cohere:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
groq:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
openrouter:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
ai21:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
cloudflare:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
deepinfra:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
deepseek:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
mistral:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
perplexity:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
voyageai:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
xai:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
jina:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
ernie:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
hunyuan:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
minimax:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
moonshot:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
qianwen:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
zhipuai:
headerName: Authorization
valueFormat: "Bearer %s"
valueFormat: 'Bearer %s'
allowedDomains:
# Coyote release + self-update + model-registry sync
- "github.com:443"
- "api.github.com:443"
- "raw.githubusercontent.com:443"
- "objects.githubusercontent.com:443"
- "*.githubusercontent.com:443"
# Coyote install paths (cargo install + uv + rustup + Python tool deps at runtime)
- "crates.io:443"
- "static.crates.io:443"
- "pypi.org:443"
- "files.pythonhosted.org:443"
- "astral.sh:443"
- "sh.rustup.rs:443"
- "static.rust-lang.org:443"
- 'github.com:443'
- 'api.github.com:443'
- 'raw.githubusercontent.com:443'
- 'objects.githubusercontent.com:443'
- '*.githubusercontent.com:443'
# Package managers and developer tools (cargo, uv, pip — useful at runtime for user installs)
- 'crates.io:443'
- 'static.crates.io:443'
- 'pypi.org:443'
- 'files.pythonhosted.org:443'
- 'astral.sh:443'
- 'sh.rustup.rs:443'
- 'static.rust-lang.org:443'
# LLM model OAuth + API endpoints
- "claude.ai:443"
- "console.anthropic.com:443"
- "accounts.google.com:443"
- 'claude.ai:443'
- 'console.anthropic.com:443'
- 'accounts.google.com:443'
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
# (*-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
# cannot rewrite. Domains are allow-listed; credentials must be injected
# separately (see README "Extending").
- "*.amazonaws.com:443"
- "models.inference.ai.azure.com:443"
- '*.amazonaws.com:443'
- 'models.inference.ai.azure.com:443'
credentials:
sources:
@@ -210,9 +210,10 @@ credentials:
environment:
variables:
IS_SANDBOX: "1"
IS_SANDBOX: '1'
COYOTE_LOG_LEVEL: INFO
COYOTE_CONFIG_DIR: /home/agent/.config/coyote
EDITOR: nano
proxyManaged:
- OPENAI_API_KEY
- ANTHROPIC_API_KEY
@@ -238,73 +239,14 @@ environment:
- ZHIPUAI_API_KEY
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:
- command:
[
"sh",
"-c",
'sh',
'-c',
'test -f "$HOME/.config/coyote/config.yaml" || coyote --info >/dev/null 2>&1 || true',
]
user: "1000"
user: '1000'
background: false
description: Bootstrap Coyote config directory on first sandbox start
+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.
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
- 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:
- **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.
- **Source code** — IWE only understands markdown.
+2
View File
@@ -13,6 +13,8 @@
model: openai:gpt-4o # Specify the LLM to use
temperature: null # Set default temperature parameter, range (0, 1)
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
# Only valid when the agent's model declares reasoning_levels.
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
name: <agent-name> # Name of the agent, used in the UI and logs
description: <description> # Description of the agent, used in the UI
+26 -4
View File
@@ -2,6 +2,8 @@
model: openai:gpt-4o # Specify the LLM to use
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
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 ----
stream: true # Controls whether to use the stream-style APIs when querying for completions from LLM clients.
@@ -31,7 +33,7 @@ sync_models_url: > # URL to sync model changes from
left_prompt:
'{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '
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 ----
# See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault.
@@ -134,6 +136,14 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
# - slack
# Example (comma-separated form):
# 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 are modular knowledge or capability packs the LLM can load and unload mid-conversation.
@@ -179,8 +189,8 @@ summary_context_prompt: > # The text prompt used for including the summar
# ---- Memory ----
# 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`)
# and global presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
# Memory is opt-in by workspace presence (`.coyote/memory/MEMORY.md`) and global
# presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
# even when memory files exist. The cascade is: agent > session > role > app.
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
# the LLM needs before it will write any memory.
@@ -190,6 +200,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).
# Indexes plus drill file bodies are injected up to this cap.
# ---- Workspace Instructions ----
# Human-curated project instructions injected read-only into the system prompt, in full.
# Coyote walks up from the current directory and injects the first match from the file
# chain below (per directory, in order). Scaffold with `coyote --init-instructions`.
# Disable per-invocation with --no-workspace-instructions, or override the chain with
# repeatable --workspace-instructions-file flags.
workspace_instructions: null # null/true = inject when an instructions file exists; false = never inject
workspace_instructions_files: null # File name chain to search, in priority order.
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md]
# Set to a custom list to reorder or drop fallbacks, e.g.:
# workspace_instructions_files: [COYOTE.md]
# ---- RAG ----
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
rag_embedding_model: null # Specifies the embedding model used for context retrieval
@@ -199,7 +221,7 @@ rag_chunk_size: null # Defines the size of chunks for document proce
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_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
rag_template: |
Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
+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
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
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)
- fs_ls # or a comma-separated string (e.g. `enabled_tools: fs_ls,fs_cat`).
- 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
temperature: 0.0 # Default sampling temperature 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
- web_search_coyote.sh
@@ -227,7 +229,7 @@ nodes:
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_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
state_updates: # {{output}} = { context: <str>, sources: [<path>, ...] }
context: "{{output.context}}" # writes `context` -> `reducers.context = concat`
@@ -394,6 +396,7 @@ nodes:
- mcp:ddg-search # `mcp:<server>` includes that server's functions
model: claude:claude-haiku-4-5 # Optional per-node model override
temperature: 0.3 # Optional per-node sampling override
reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high)
max_attempts: 2 # Retry count on transient errors only. Default 1.
max_iterations: 10 # Tool-call-loop turn cap. Default 10.
fallback: review # Route here if all attempts fail
+13
View File
@@ -23,3 +23,16 @@ fmt:
[arg('build_type', pattern="debug|release")]
build build_type='debug':
@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 \
.
+278 -2
View File
@@ -3,6 +3,33 @@
# - https://platform.openai.com/docs/api-reference/chat
- provider: openai
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
max_input_tokens: 1050000
max_output_tokens: 128000
@@ -10,6 +37,8 @@
output_price: 30
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: medium
- name: gpt-5.5-pro
max_input_tokens: 1050000
max_output_tokens: 128000
@@ -17,6 +46,8 @@
output_price: 180
supports_vision: true
supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: high
- name: gpt-5.4
max_input_tokens: 1050000
max_output_tokens: 128000
@@ -24,6 +55,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: gpt-5.4-pro
max_input_tokens: 1050000
max_output_tokens: 128000
@@ -31,6 +64,8 @@
output_price: 180
supports_vision: true
supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: medium
- name: gpt-5.4-mini
max_input_tokens: 400000
max_output_tokens: 128000
@@ -38,6 +73,8 @@
output_price: 4.5
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: gpt-5.4-nano
max_input_tokens: 400000
max_output_tokens: 128000
@@ -45,6 +82,8 @@
output_price: 1.25
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: gpt-5.3-codex
max_input_tokens: 400000
max_output_tokens: 128000
@@ -52,6 +91,8 @@
output_price: 14
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh]
default_reasoning_effort: medium
- name: chat-latest
max_input_tokens: 400000
max_output_tokens: 128000
@@ -66,6 +107,17 @@
output_price: 14
supports_vision: 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
max_input_tokens: 400000
max_output_tokens: 128000
@@ -73,6 +125,8 @@
output_price: 10
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high]
default_reasoning_effort: none
- name: gpt-5.1-chat-latest
max_input_tokens: 400000
max_output_tokens: 128000
@@ -80,6 +134,8 @@
output_price: 10
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high]
default_reasoning_effort: none
- name: gpt-5
max_input_tokens: 400000
max_output_tokens: 128000
@@ -87,6 +143,8 @@
output_price: 10
supports_vision: true
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: gpt-5-chat-latest
max_input_tokens: 400000
max_output_tokens: 128000
@@ -94,6 +152,8 @@
output_price: 10
supports_vision: true
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: gpt-5-mini
max_input_tokens: 400000
max_output_tokens: 128000
@@ -151,6 +211,8 @@
supports_vision: true
supports_function_calling: true
system_prompt_prefix: Formatting re-enabled
reasoning_levels: [low, medium, high]
default_reasoning_effort: medium
patch:
body:
max_tokens: null
@@ -264,18 +326,24 @@
input_price: 0.2
output_price: 1.5
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: 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: 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: gemini-3.1-pro-preview
max_input_tokens: 1048576
max_output_tokens: 65535
@@ -283,6 +351,8 @@
output_price: 2.5
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: gemini-2.5-flash
max_input_tokens: 1048576
max_output_tokens: 65536
@@ -290,6 +360,8 @@
output_price: 0
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: low
- name: gemini-2.5-pro
max_input_tokens: 1048576
max_output_tokens: 65536
@@ -297,6 +369,8 @@
output_price: 0
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: gemini-2.5-flash-lite
max_input_tokens: 1000000
max_output_tokens: 64000
@@ -308,10 +382,14 @@
max_input_tokens: 1048576
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, high]
default_reasoning_level: high
- name: 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: gemma-3-27b-it
max_input_tokens: 131072
max_output_tokens: 8192
@@ -337,6 +415,8 @@
output_price: 50
supports_function_calling: true
supports_vision: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-8
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -345,6 +425,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-7
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -353,6 +435,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -361,6 +445,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: claude-opus-4-6:thinking
real_name: claude-opus-4-6
max_input_tokens: 200000
@@ -385,6 +471,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-sonnet-4-6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -393,6 +481,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: claude-sonnet-4-6:thinking
real_name: claude-sonnet-4-6
max_input_tokens: 200000
@@ -717,13 +807,46 @@
# - https://docs.x.ai/docs/api-reference#chat-completions
- provider: xai
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
max_input_tokens: 2000000
max_input_tokens: 1000000
input_price: 0.2
output_price: 0.5
supports_function_calling: true
- name: grok-4-1-fast-reasoning
max_input_tokens: 2000000
max_input_tokens: 1000000
input_price: 0.2
output_price: 0.5
supports_function_calling: true
@@ -835,18 +958,24 @@
input_price: 0.2
output_price: 1.5
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: 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: 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, high]
default_reasoning_effort: minimal
- name: gemini-3.1-pro-preview
max_input_tokens: 1048576
max_output_tokens: 65536
@@ -854,6 +983,8 @@
output_price: 12
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: gemini-2.5-flash
max_input_tokens: 1048576
max_output_tokens: 65535
@@ -861,6 +992,8 @@
output_price: 2.5
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: medium
- name: gemini-2.5-pro
max_input_tokens: 1048576
max_output_tokens: 65536
@@ -868,6 +1001,8 @@
output_price: 10
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: gemini-2.5-flash-lite
max_input_tokens: 1048576
max_output_tokens: 65536
@@ -879,10 +1014,14 @@
max_input_tokens: 1048576
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, high]
default_reasoning_effort: high
- name: gemini-3-flash-preview
max_input_tokens: 1048576
supports_vision: true
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: high
- name: claude-fable-5
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -891,6 +1030,8 @@
output_price: 50
supports_function_calling: true
supports_vision: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-8
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -899,6 +1040,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-7
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -907,6 +1050,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -938,6 +1083,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-sonnet-4-6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -946,6 +1093,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: claude-sonnet-4-6:thinking
real_name: claude-sonnet-4-6
max_input_tokens: 200000
@@ -1078,6 +1227,8 @@
output_price: 50
supports_function_calling: true
supports_vision: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: us.anthropic.claude-opus-4-8
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -1086,6 +1237,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: us.anthropic.claude-opus-4-7
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -1094,6 +1247,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: us.anthropic.claude-opus-4-6-v1
max_input_tokens: 200000
max_output_tokens: 8192
@@ -1102,6 +1257,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: us.anthropic.claude-opus-4-6-v1:thinking
real_name: us.anthropic.claude-opus-4-6-v1
max_input_tokens: 200000
@@ -1127,6 +1284,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: us.anthropic.claude-sonnet-4-6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -1135,6 +1294,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: us.anthropic.claude-sonnet-4-6:thinking
real_name: us.anthropic.claude-sonnet-4-6
max_input_tokens: 200000
@@ -1644,6 +1805,33 @@
# - https://openrouter.ai/docs/api-reference/chat-completion
- provider: openrouter
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
max_input_tokens: 1050000
max_output_tokens: 128000
@@ -1651,6 +1839,8 @@
output_price: 30
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: medium
- name: openai/gpt-5.5-pro
max_input_tokens: 1050000
max_output_tokens: 128000
@@ -1658,6 +1848,8 @@
output_price: 180
supports_vision: true
supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: high
- name: openai/gpt-5.4
max_input_tokens: 1050000
max_output_tokens: 128000
@@ -1665,6 +1857,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: openai/gpt-5.4-pro
max_input_tokens: 1050000
max_output_tokens: 128000
@@ -1672,6 +1866,8 @@
output_price: 180
supports_vision: true
supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: medium
- name: openai/gpt-5.4-mini
max_input_tokens: 400000
max_output_tokens: 128000
@@ -1679,6 +1875,8 @@
output_price: 4.5
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: openai/gpt-5.4-nano
max_input_tokens: 400000
max_output_tokens: 128000
@@ -1686,6 +1884,8 @@
output_price: 1.25
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: openai/gpt-5.3-codex
max_input_tokens: 400000
max_output_tokens: 128000
@@ -1693,6 +1893,8 @@
output_price: 14
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh]
default_reasoning_effort: medium
- name: openai/gpt-5.2
max_input_tokens: 400000
max_output_tokens: 128000
@@ -1700,6 +1902,17 @@
output_price: 14
supports_vision: 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
max_input_tokens: 400000
max_output_tokens: 128000
@@ -1707,6 +1920,8 @@
output_price: 10
supports_vision: true
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: openai/gpt-5-mini
max_input_tokens: 400000
max_output_tokens: 128000
@@ -1744,18 +1959,67 @@
input_price: 0.04
output_price: 0.16
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
max_input_tokens: 1048576
input_price: 0.3
output_price: 2.5
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: low
- name: google/gemini-2.5-pro
max_input_tokens: 1048576
input_price: 1.25
output_price: 10
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: google/gemini-2.5-flash-lite
max_input_tokens: 1048576
input_price: 0.3
@@ -1785,6 +2049,8 @@
output_price: 50
supports_function_calling: true
supports_vision: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: anthropic/claude-opus-4-8
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -1793,6 +2059,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: anthropic/claude-opus-4-7
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -1801,6 +2069,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: anthropic/claude-opus-4.6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -1809,6 +2079,8 @@
output_price: 25
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: anthropic/claude-sonnet-5
max_input_tokens: 1000000
max_output_tokens: 128000
@@ -1817,6 +2089,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: anthropic/claude-sonnet-4.6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -1825,6 +2099,8 @@
output_price: 15
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: anthropic/claude-opus-4.5
max_input_tokens: 200000
max_output_tokens: 8192
+162 -110
View File
@@ -43,6 +43,10 @@ use std::io::{Read, stdin};
),
)]
pub struct Cli {
/// Input text
#[arg(trailing_var_arg = true)]
text: Vec<String>,
/// Select a LLM model
#[arg(short, long, add = ArgValueCompleter::new(model_completer))]
pub model: Option<String>,
@@ -52,30 +56,6 @@ pub struct Cli {
/// Select a role
#[arg(short, long, add = ArgValueCompleter::new(role_completer))]
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
#[arg(short = 'e', long)]
pub execute: bool,
@@ -88,113 +68,185 @@ pub struct Cli {
/// Turn off stream mode
#[arg(short = 'S', long)]
pub no_stream: bool,
/// Disable memory for this invocation
#[arg(long)]
pub no_memory: 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
#[arg(long)]
pub dry_run: bool,
/// Display information
/// Disable loading workspace MCP servers from .coyote/mcp.json, .coyote/.mcp.json, or .mcp.json
#[arg(long)]
pub info: bool,
/// Build all configured Bash tool scripts
pub no_workspace_mcp: bool,
/// Disable memory for this invocation
#[arg(long)]
pub build_tools: bool,
/// Reinstall bundled assets, overwriting any local changes
#[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
pub no_memory: bool,
/// Disable loading workspace instructions (COYOTE.md/AGENTS.md/CLAUDE.md/etc.) for this invocation
#[arg(long)]
pub sync_models: bool,
/// List all available chat models
pub no_workspace_instructions: bool,
/// 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)]
pub list_models: bool,
/// List all roles
#[arg(long)]
pub list_roles: bool,
/// List all sessions
#[arg(long)]
pub list_sessions: bool,
/// List all agents
#[arg(long)]
pub list_agents: bool,
/// List all RAGs
#[arg(long)]
pub list_rags: bool,
/// List all macros
#[arg(long)]
pub list_macros: bool,
/// List all installed skills
#[arg(long)]
pub list_skills: bool,
pub dangerously_skip_permissions: bool,
/// Start or join a session
#[arg(short = 's', long, help_heading = "Session & Memory", add = ArgValueCompleter::new(session_completer))]
pub session: Option<Option<String>>,
/// Ensure the session is empty
#[arg(long, help_heading = "Session & Memory")]
pub empty_session: bool,
/// Ensure the new conversation is saved to the session
#[arg(long, help_heading = "Session & Memory")]
pub save_session: bool,
/// Bootstrap a memory marker so coyote begins loading memory next run
#[arg(
long,
value_name = "SCOPE",
value_enum,
help_heading = "Session & Memory"
)]
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
/// `--skill <NAME>` is given and the skill doesn't exist, opens $EDITOR
/// with a scaffold to create it.
#[arg(long, value_name = "NAME")]
#[arg(long, value_name = "NAME", help_heading = "Session & Memory")]
pub skill: Vec<String>,
/// Input text
#[arg(trailing_var_arg = true)]
text: Vec<String>,
/// Tail logs
#[arg(long)]
pub tail_logs: bool,
/// Disable colored log output
#[arg(long, requires = "tail_logs")]
pub disable_log_colors: bool,
/// Add a secret to the Coyote vault
#[arg(long, value_name = "SECRET_NAME", exclusive = true)]
pub add_secret: Option<String>,
/// Decrypt a secret from the Coyote vault and print the plaintext
#[arg(long, value_name = "SECRET_NAME", exclusive = true, 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, 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, add = ArgValueCompleter::new(secrets_completer))]
pub delete_secret: Option<String>,
/// List all secrets stored in the Coyote vault
#[arg(long, exclusive = true)]
pub list_secrets: bool,
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
#[arg(long, exclusive = true, value_name = "CLIENT_NAME")]
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", add = ArgValueCompleter::new(mcp_server_completer))]
pub auth_mcp: Option<String>,
/// Generate static shell completion scripts
#[arg(long, value_name = "SHELL", value_enum)]
pub completions: Option<ShellCompletion>,
/// Start an agent
#[arg(short = 'a', long, help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(agent_completer))]
pub agent: Option<String>,
/// Set agent variables
#[arg(long, value_names = ["NAME", "VALUE"], num_args = 2, help_heading = "Agents, RAG & Macros")]
pub agent_variable: Vec<String>,
/// Start a RAG
#[arg(long, help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(rag_completer))]
pub rag: Option<String>,
/// Rebuild the RAG to sync document changes
#[arg(long, help_heading = "Agents, RAG & Macros")]
pub rebuild_rag: bool,
/// Execute a macro
#[arg(long = "macro", value_name = "MACRO", help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(macro_completer))]
pub macro_name: Option<String>,
/// List all available chat models
#[arg(long, help_heading = "List & Discovery")]
pub list_models: bool,
/// List all roles
#[arg(long, help_heading = "List & Discovery")]
pub list_roles: bool,
/// List all sessions
#[arg(long, help_heading = "List & Discovery")]
pub list_sessions: bool,
/// List all agents
#[arg(long, help_heading = "List & Discovery")]
pub list_agents: bool,
/// List all RAGs
#[arg(long, help_heading = "List & Discovery")]
pub list_rags: bool,
/// 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
#[arg(long, value_name = "VERSION")]
#[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")]
pub update: Option<Option<String>>,
/// 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,
/// 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
#[arg(long, value_name = "NAME")]
#[arg(long, value_name = "NAME", help_heading = "Sandbox")]
pub sandbox: Option<Option<String>>,
/// 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,
/// 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,
/// 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 {
+2 -2
View File
@@ -50,7 +50,7 @@ fn prepare_chat_completions(
let url = format!(
"{}/openai/deployments/{}/chat/completions?api-version=2024-12-01-preview",
&api_base,
api_base,
self_.model.real_name()
);
@@ -69,7 +69,7 @@ fn prepare_embeddings(self_: &AzureOpenAIClient, data: &EmbeddingsData) -> Resul
let url = format!(
"{}/openai/deployments/{}/embeddings?api-version=2024-10-21",
&api_base,
api_base,
self_.model.real_name()
);
+10 -1
View File
@@ -325,6 +325,7 @@ fn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Resu
mut messages,
temperature,
top_p,
reasoning_effort,
functions,
stream: _,
} = data;
@@ -396,6 +397,11 @@ fn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Resu
}))
}
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!({
"toolUse": {
"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 {
body["inferenceConfig"]["topP"] = v.into();
}
if let Some(v) = reasoning_effort {
body["additionalModelRequestFields"] = json!({ "output_config": { "effort": v } });
}
if let Some(functions) = functions {
let tools: Vec<_> = functions
.iter()
@@ -520,7 +529,7 @@ fn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
bail!("Invalid response data: {data}");
}
let output = ChatCompletionsOutput { text, tool_calls };
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
Ok(output)
}
+49 -3
View File
@@ -168,12 +168,22 @@ pub async fn claude_chat_completions_streaming(
let mut function_arguments = String::new();
let mut function_id = String::new();
let mut reasoning_state = 0;
let mut thinking_text = String::new();
let mut thinking_signature = String::new();
let handle = |message: SseMessage| -> Result<bool> {
let data: Value = serde_json::from_str(&message.data)?;
debug!("stream-data: {data}");
if let Some(typ) = data["type"].as_str() {
match typ {
"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)) = (
data["content_block"]["type"].as_str(),
data["content_block"]["name"].as_str(),
@@ -206,7 +216,10 @@ pub async fn claude_chat_completions_streaming(
handler.text("<think>\n")?;
reasoning_state = 1;
}
thinking_text.push_str(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)) = (
!function_name.is_empty(),
data["delta"]["partial_json"].as_str(),
@@ -218,6 +231,10 @@ pub async fn claude_chat_completions_streaming(
if reasoning_state == 1 {
handler.text("\n</think>\n\n")?;
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() {
let arguments: Value = if function_arguments.is_empty() {
@@ -251,6 +268,7 @@ pub fn claude_build_chat_completions_body(
mut messages,
temperature,
top_p,
reasoning_effort,
functions,
stream,
} = data;
@@ -312,13 +330,25 @@ pub fn claude_build_chat_completions_body(
}) => {
let mut assistant_parts = vec![];
let mut user_parts = vec![];
if !text.is_empty() {
for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking {
assistant_parts.push(json!(block));
}
let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str())
} 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": text,
"text": round_text,
}))
}
for tool_result in tool_results {
}
assistant_parts.push(json!({
"type": "tool_use",
"id": tool_result.call.id,
@@ -369,6 +399,9 @@ pub fn claude_build_chat_completions_body(
if let Some(v) = top_p {
body["top_p"] = v.into();
}
if let Some(v) = reasoning_effort {
body["output_config"] = json!({ "effort": v });
}
if stream {
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 reasoning = None;
let mut tool_calls = vec![];
let mut thinking = vec![];
if let Some(list) = data["content"].as_array() {
for item in list {
match item["type"].as_str() {
Some("thinking") => {
if let Some(v) = item["thinking"].as_str() {
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") => {
@@ -443,6 +488,7 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
let output = ChatCompletionsOutput {
text: text.to_string(),
tool_calls,
thinking,
};
Ok(output)
}
+1 -1
View File
@@ -244,6 +244,6 @@ fn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
if text.is_empty() && tool_calls.is_empty() {
bail!("Invalid response data: {data}");
}
let output = ChatCompletionsOutput { text, tool_calls };
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
Ok(output)
}
+12 -3
View File
@@ -286,6 +286,7 @@ pub struct ChatCompletionsData {
pub messages: Vec<Message>,
pub temperature: Option<f64>,
pub top_p: Option<f64>,
pub reasoning_effort: Option<String>,
pub functions: Option<Vec<FunctionDeclaration>>,
pub stream: bool,
}
@@ -294,6 +295,7 @@ pub struct ChatCompletionsData {
pub struct ChatCompletionsOutput {
pub text: String,
pub tool_calls: Vec<ToolCall>,
pub thinking: Vec<ThinkingBlock>,
}
impl ChatCompletionsOutput {
@@ -434,6 +436,7 @@ pub async fn call_chat_completions(
let ChatCompletionsOutput {
mut text,
tool_calls,
thinking,
..
} = ret;
if !text.is_empty() {
@@ -444,7 +447,10 @@ pub async fn call_chat_completions(
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
.iter()
.for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone()));
@@ -478,13 +484,16 @@ pub async fn call_chat_completions_streaming(
render_ret?;
let (text, tool_calls) = handler.take();
let (text, tool_calls, thinking) = handler.take();
match send_ret {
Ok(_) => {
if !text.is_empty() && !text.ends_with('\n') {
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
.iter()
.for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone()));
+20 -2
View File
@@ -118,6 +118,9 @@ impl MessageContent {
lines.push(text.clone())
}
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()];
if let Some((agent_name, functions)) = agent_info
&& functions.contains(&tool_result.call.name)
@@ -185,6 +188,17 @@ pub struct ImageUrl {
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)]
pub struct MessageContentToolCalls {
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.text.clear();
self.sequence = true;
}
}
+12
View File
@@ -289,6 +289,14 @@ impl Model {
}
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)]
@@ -316,6 +324,10 @@ pub struct ModelData {
pub supports_vision: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
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")]
no_stream: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
+50 -18
View File
@@ -356,6 +356,7 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
messages,
temperature,
top_p,
reasoning_effort,
functions,
stream,
} = data;
@@ -369,7 +370,7 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
match content {
MessageContent::ToolCalls(MessageContentToolCalls {
tool_results,
text: _,
text,
sequence,
}) => {
if !sequence {
@@ -386,9 +387,12 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
})
})
.collect();
let mut messages = vec![
json!({ "role": MessageRole::Assistant, "tool_calls": tool_calls }),
];
let mut assistant_message =
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 {
messages.push(json!({
"role": "tool",
@@ -398,9 +402,13 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
}
messages
} else {
tool_results.into_iter().flat_map(|tool_result| {
vec![
json!({
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": [
{
@@ -412,7 +420,12 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
},
}
]
}),
});
if let Some(round_text) = round_text {
assistant_message["content"] = strip_think_tag(&round_text).into();
}
vec![
assistant_message,
json!({
"role": "tool",
"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 {
body["top_p"] = v.into();
}
if let Some(v) = reasoning_effort {
body["reasoning_effort"] = v.into();
}
if stream {
body["stream"] = true.into();
}
@@ -517,7 +533,7 @@ pub fn openai_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
} else {
text.to_string()
};
let output = ChatCompletionsOutput { text, tool_calls };
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
Ok(output)
}
@@ -534,6 +550,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
messages,
temperature,
top_p,
reasoning_effort,
functions,
stream,
} = data;
@@ -547,24 +564,36 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
match content {
MessageContent::ToolCalls(MessageContentToolCalls {
tool_results,
text: _,
text,
sequence: _,
}) => tool_results
.into_iter()
.flat_map(|tool_result| {
vec![
json!({
.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 items = vec![];
if let Some(round_text) = round_text {
items.push(json!({
"role": MessageRole::Assistant,
"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(),
}),
json!({
}));
items.push(json!({
"type": "function_call_output",
"call_id": tool_result.call.id,
"output": tool_result.output.to_string(),
}),
]
}));
items
})
.collect(),
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 {
body["top_p"] = v.into();
}
if let Some(v) = reasoning_effort {
body["reasoning"] = json!({ "effort": v });
}
if stream {
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() {
bail!("Invalid response data: {data}");
}
Ok(ChatCompletionsOutput { text, tool_calls })
Ok(ChatCompletionsOutput { text, tool_calls, ..Default::default() })
}
pub async fn openai_responses_streaming(
+13 -4
View File
@@ -1,4 +1,4 @@
use super::{ToolCall, catch_error};
use super::{ThinkingBlock, ToolCall, catch_error};
use crate::utils::AbortSignal;
use anyhow::{Context, Result, anyhow, bail};
@@ -13,6 +13,7 @@ pub struct SseHandler {
abort_signal: AbortSignal,
buffer: String,
tool_calls: Vec<ToolCall>,
thinking: Vec<ThinkingBlock>,
last_tool_calls: Vec<ToolCall>,
max_call_repeats: usize,
call_repeat_chain_len: usize,
@@ -26,6 +27,7 @@ impl SseHandler {
abort_signal,
buffer: String::new(),
tool_calls: Vec::new(),
thinking: Vec::new(),
last_tool_calls: Vec::new(),
max_call_repeats: 2,
call_repeat_chain_len: 3,
@@ -170,6 +172,10 @@ impl SseHandler {
message
}
pub fn thinking_block(&mut self, block: ThinkingBlock) {
self.thinking.push(block);
}
pub fn abort(&self) -> AbortSignal {
self.abort_signal.clone()
}
@@ -179,11 +185,14 @@ impl SseHandler {
&self.last_tool_calls
}
pub fn take(self) -> (String, Vec<ToolCall>) {
pub fn take(self) -> (String, Vec<ToolCall>, Vec<ThinkingBlock>) {
let Self {
buffer, tool_calls, ..
buffer,
tool_calls,
thinking,
..
} = self;
(buffer, tool_calls)
(buffer, tool_calls, thinking)
}
}
+16 -5
View File
@@ -322,7 +322,7 @@ fn gemini_extract_chat_completions_text(data: &Value) -> Result<ChatCompletionsO
bail!("Invalid response data: {data}");
}
}
let output = ChatCompletionsOutput { text, tool_calls };
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
Ok(output)
}
@@ -334,6 +334,7 @@ pub fn gemini_build_chat_completions_body(
mut messages,
temperature,
top_p,
reasoning_effort,
functions,
stream: _,
} = data;
@@ -371,8 +372,15 @@ pub fn gemini_build_chat_completions_body(
.collect();
vec![json!({ "role": role, "parts": parts })]
},
MessageContent::ToolCalls(MessageContentToolCalls { tool_results, .. }) => {
let model_parts: Vec<Value> = tool_results.iter().map(|tool_result| {
MessageContent::ToolCalls(MessageContentToolCalls { tool_results, text, .. }) => {
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!({
"functionCall": {
"name": tool_result.call.name,
@@ -382,8 +390,8 @@ pub fn gemini_build_chat_completions_body(
if let Some(sig) = &tool_result.call.thought_signature {
part["thoughtSignature"] = json!(sig);
}
part
}).collect();
model_parts.push(part);
}
let function_parts: Vec<Value> = tool_results.into_iter().map(|tool_result| {
json!({
"functionResponse": {
@@ -426,6 +434,9 @@ pub fn gemini_build_chat_completions_body(
if let Some(v) = top_p {
body["generationConfig"]["topP"] = v.into();
}
if let Some(v) = reasoning_effort {
body["generation_config"]["thinking_level"] = v.into();
}
if let Some(functions) = functions {
// Gemini doesn't support functions with parameters that have empty properties, so we need to patch it.
+27 -10
View File
@@ -43,6 +43,8 @@ pub struct Agent {
graph_rags: HashMap<String, Arc<Rag>>,
model: Model,
vault: GlobalVault,
is_graph: bool,
enabled_tools: Option<Vec<String>>,
}
impl Agent {
@@ -243,6 +245,8 @@ impl Agent {
graph_rags,
model,
vault: app_state.vault.clone(),
is_graph: graph_for_rag.is_some(),
enabled_tools: None,
})
}
@@ -339,6 +343,10 @@ impl Agent {
&self.name
}
pub fn is_graph(&self) -> bool {
self.is_graph
}
pub fn functions(&self) -> &Functions {
&self.functions
}
@@ -575,8 +583,12 @@ impl RoleLike for Agent {
self.config.top_p
}
fn reasoning_effort(&self) -> Option<String> {
self.config.reasoning_effort.clone()
}
fn enabled_tools(&self) -> Option<Vec<String>> {
None
self.enabled_tools.clone()
}
fn enabled_mcp_servers(&self) -> Option<Vec<String>> {
@@ -596,19 +608,18 @@ impl RoleLike for Agent {
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>>) {
match value {
Some(tools) => {
self.config.global_tools = tools
self.enabled_tools = value.map(|tools| {
tools
.into_iter()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect::<Vec<_>>();
}
None => {
self.config.global_tools.clear();
}
}
.collect::<Vec<_>>()
});
}
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
@@ -637,6 +648,8 @@ pub struct AgentConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_session: Option<String>,
#[serde(default)]
pub auto_continue: bool,
@@ -732,6 +745,7 @@ impl AgentConfig {
model_id: graph.model.clone(),
temperature: graph.temperature,
top_p: graph.top_p,
reasoning_effort: graph.reasoning_effort.clone(),
description: graph.description.clone(),
global_tools: graph.global_tools.clone(),
mcp_servers: graph.mcp_servers.clone(),
@@ -766,6 +780,9 @@ impl AgentConfig {
if let Some(v) = read_env_value::<f64>(&with_prefix("top_p")) {
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"))
&& let Ok(v) = serde_json::from_str(&v)
{
+53 -7
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::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name};
@@ -21,6 +21,7 @@ pub struct AppConfig {
pub model_id: String,
pub temperature: Option<f64>,
pub top_p: Option<f64>,
pub reasoning_effort: Option<String>,
pub dry_run: bool,
pub stream: bool,
@@ -68,6 +69,9 @@ pub struct AppConfig {
pub memory_cap_with_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_reranker_model: Option<String>,
pub rag_top_k: usize,
@@ -88,6 +92,7 @@ pub struct AppConfig {
pub user_agent: Option<String>,
pub save_shell_history: bool,
pub no_workspace_mcp: bool,
pub sync_models_url: Option<String>,
pub clients: Vec<ClientConfig>,
@@ -99,6 +104,7 @@ impl Default for AppConfig {
model_id: Default::default(),
temperature: None,
top_p: None,
reasoning_effort: None,
dry_run: false,
stream: true,
@@ -143,6 +149,9 @@ impl Default for AppConfig {
memory_cap_with_tools: None,
memory_cap_without_tools: None,
workspace_instructions: None,
workspace_instructions_files: None,
rag_embedding_model: None,
rag_reranker_model: None,
rag_top_k: 5,
@@ -162,6 +171,7 @@ impl Default for AppConfig {
user_agent: None,
save_shell_history: true,
no_workspace_mcp: false,
sync_models_url: None,
clients: vec![],
@@ -175,6 +185,7 @@ impl AppConfig {
model_id: config.model_id,
temperature: config.temperature,
top_p: config.top_p,
reasoning_effort: None,
dry_run: config.dry_run,
stream: config.stream,
@@ -219,6 +230,9 @@ impl AppConfig {
memory_cap_with_tools: config.memory_cap_with_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_reranker_model: config.rag_reranker_model,
rag_top_k: config.rag_top_k,
@@ -238,6 +252,7 @@ impl AppConfig {
user_agent: config.user_agent,
save_shell_history: config.save_shell_history,
no_workspace_mcp: false,
sync_models_url: config.sync_models_url,
clients: config.clients,
@@ -250,6 +265,7 @@ impl AppConfig {
app_config.setup_document_loaders();
app_config.setup_user_agent();
app_config.resolve_model()?;
app_config.validate_reasoning_effort()?;
Ok(app_config)
}
@@ -270,6 +286,31 @@ impl AppConfig {
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<()> {
if self.model_id.is_empty() {
let models = list_models(self, crate::client::ModelType::Chat);
@@ -308,16 +349,18 @@ impl AppConfig {
pub fn editor(&self) -> Result<String> {
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()))
.unwrap_or_else(|| {
if cfg!(windows) {
&& which::which(&editor).is_ok()
{
return Some(editor);
}
let default = if cfg!(windows) {
"notepad".to_string()
} else {
"nano".to_string()
}
});
which::which(&editor).ok().map(|_| editor)
};
which::which(&default).ok().map(|_| default)
})
.clone()
.ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable."))
@@ -421,6 +464,9 @@ impl AppConfig {
if let Some(v) = super::read_env_value::<f64>(&get_env_name("top_p")) {
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")) {
self.dry_run = v;
+5
View File
@@ -253,6 +253,10 @@ impl Input {
patch_messages(&mut messages, model);
model.guard_max_input_tokens(&messages)?;
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 fns = self.functions.clone();
if let Some(vec) = &fns {
@@ -268,6 +272,7 @@ impl Input {
messages,
temperature,
top_p,
reasoning_effort,
functions,
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,
};
use anyhow::{Result, anyhow};
use anyhow::Result;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::Path;
@@ -111,10 +111,10 @@ impl McpFactory {
.await
.map_err(|e| {
if is_auth_required_error(&e) {
anyhow!(
e.context(format!(
"MCP server '{name}' requires OAuth authentication. \
Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate."
)
))
} else {
e
}
+25 -45
View File
@@ -7,41 +7,27 @@ use serde::{Deserialize, Serialize};
use crate::config::{
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_WITHOUT_TOOLS: usize = 12_000;
#[derive(Debug, Clone)]
pub enum WorkspaceMemory {
Structured {
workspace_root: PathBuf,
dir: PathBuf,
},
Lite {
workspace_root: PathBuf,
file: PathBuf,
},
pub struct WorkspaceMemory {
pub workspace_root: PathBuf,
pub dir: PathBuf,
}
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
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() {
return Some(WorkspaceMemory::Structured {
return Some(WorkspaceMemory {
workspace_root: dir.to_path_buf(),
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
}
@@ -82,10 +68,10 @@ pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
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 entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/");
let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}");
let entry = format!("{WORKSPACE_COYOTE_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 already_present = existing.lines().any(|line| {
@@ -212,9 +198,8 @@ impl MemoryStore {
pub fn load_workspace_index(&self) -> Result<Option<String>> {
match &self.workspace {
None => Ok(None),
Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)),
Some(WorkspaceMemory::Structured { dir, .. }) => {
let index = dir.join(MEMORY_INDEX_FILE_NAME);
Some(ws) => {
let index = ws.dir.join(MEMORY_INDEX_FILE_NAME);
if index.exists() {
Ok(Some(fs::read_to_string(index)?))
} else {
@@ -231,8 +216,8 @@ impl MemoryStore {
collect_md_files(&self.global_dir, &mut out)?;
}
if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace {
collect_md_files(dir, &mut out)?;
if let Some(ws) = &self.workspace {
collect_md_files(&ws.dir, &mut out)?;
}
Ok(out)
@@ -347,7 +332,7 @@ mod tests {
let root = temp_root("phase1");
let workspace = root.join("workspace");
let workspace_memory_dir = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&workspace_memory_dir).unwrap();
fs::write(
@@ -378,18 +363,13 @@ mod tests {
}
#[test]
fn workspace_discovery_prefers_structured_over_lite() {
let root = temp_root("prefer");
fn workspace_discovery_ignores_root_instructions_file() {
let root = temp_root("no_lite");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.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();
fs::create_dir_all(&workspace).unwrap();
fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
let found = discover_workspace_memory(&workspace);
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
assert!(discover_workspace_memory(&workspace).is_none());
let _ = fs::remove_dir_all(&root);
}
@@ -415,7 +395,7 @@ mod tests {
let root = temp_root("indexes_only");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(
@@ -450,7 +430,7 @@ mod tests {
let root = temp_root("drill_bodies");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
@@ -485,7 +465,7 @@ mod tests {
let root = temp_root("cap");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
@@ -575,15 +555,15 @@ mod tests {
let root = temp_root("walk_up");
let workspace = root.join("ws");
let mem_dir = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&mem_dir).unwrap();
fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
let nested = workspace.join("src").join("deep").join("path");
fs::create_dir_all(&nested).unwrap();
let found = discover_workspace_memory(&nested);
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
let found = discover_workspace_memory(&nested).expect("workspace memory should be found");
assert_eq!(found.dir, mem_dir);
let _ = fs::remove_dir_all(&root);
}
+10 -3
View File
@@ -3,6 +3,7 @@ mod app_config;
mod app_state;
mod input;
mod install_remote;
pub(crate) mod instructions;
mod macros;
mod mcp_factory;
pub(crate) mod memory;
@@ -140,10 +141,10 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
@@ -183,7 +184,7 @@ const SUMMARIZATION_PROMPT: &str =
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 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();
@@ -244,6 +245,9 @@ pub struct Config {
pub memory_cap_with_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_reranker_model: Option<String>,
pub rag_top_k: usize,
@@ -319,6 +323,9 @@ impl Default for Config {
memory_cap_with_tools: None,
memory_cap_without_tools: None,
workspace_instructions: None,
workspace_instructions_files: None,
rag_embedding_model: None,
rag_reranker_model: None,
rag_top_k: 5,
+150 -8
View File
@@ -2,10 +2,10 @@ use super::role::Role;
use super::{
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,
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME,
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME,
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME,
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME,
MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME,
SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME,
SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
};
use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME;
@@ -118,7 +118,7 @@ pub fn global_tools_sbx_mixin_file() -> PathBuf {
pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
for dir in start.ancestors() {
let candidate = dir
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(SBX_MIXIN_FILE_NAME);
if candidate.exists() {
return Some(candidate);
@@ -193,6 +193,40 @@ pub fn skill_file(name: &str) -> PathBuf {
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<()> {
if name.is_empty() {
bail!("Skill name cannot be empty");
@@ -318,10 +352,14 @@ pub fn global_memory_index_path() -> PathBuf {
pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
workspace_root
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME)
}
pub fn workspace_memory_index_path_for(workspace_root: &Path) -> PathBuf {
workspace_memory_dir_for(workspace_root).join(MEMORY_INDEX_FILE_NAME)
}
pub fn repl_history_dir() -> PathBuf {
cache_path().join(REPL_HISTORY_DIR_NAME)
}
@@ -405,25 +443,31 @@ pub fn has_macro(name: &str) -> bool {
pub fn list_skills() -> Vec<String> {
let mut names = Vec::new();
if let Ok(rd) = read_dir(skills_dir()) {
let mut seen = HashSet::new();
for dir in [workspace_skills_dir(), skills_dir()] {
if let Ok(rd) = read_dir(dir) {
for entry in rd.flatten() {
if let Ok(file_type) = entry.file_type()
&& file_type.is_dir()
&& let Some(name) = entry.file_name().to_str()
&& !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());
}
}
}
}
names.sort_unstable();
names
}
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>> {
@@ -658,6 +702,104 @@ mod tests {
}
}
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"))
);
});
}
}
#[test]
fn sandbox_kit_override_reflects_env_var_state() {
let env_name = get_env_name("sandbox_kit");
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 set_model(&mut self, model: Model);
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_reasoning_effort(&mut self, value: Option<String>);
fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>);
}
@@ -51,6 +53,8 @@ pub struct Role {
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
@@ -116,6 +120,9 @@ impl Role {
"model" => role.model_id = value.as_str().map(|v| v.to_string()),
"temperature" => role.temperature = value.as_f64(),
"top_p" => role.top_p = value.as_f64(),
"reasoning_effort" => {
role.reasoning_effort = value.as_str().map(|v| v.to_string())
}
"enabled_tools" => role.enabled_tools = parse_string_or_array(value),
"enabled_mcp_servers" => {
role.enabled_mcp_servers = parse_string_or_array(value)
@@ -170,6 +177,9 @@ impl Role {
if let Some(top_p) = self.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 {
let inline = serde_json::to_string(enabled_tools).unwrap_or_else(|_| "[]".to_string());
metadata.push(format!("enabled_tools: {inline}"));
@@ -245,12 +255,14 @@ impl Role {
pub fn sync<T: RoleLike>(&mut self, role_like: &T) {
let model = role_like.model();
let reasoning_effort = role_like.reasoning_effort();
let temperature = role_like.temperature();
let top_p = role_like.top_p();
let enabled_tools = role_like.enabled_tools();
let enabled_mcp_servers = role_like.enabled_mcp_servers();
self.batch_set(
model,
reasoning_effort,
temperature,
top_p,
enabled_tools,
@@ -261,12 +273,16 @@ impl Role {
pub fn batch_set(
&mut self,
model: &Model,
reasoning_effort: Option<String>,
temperature: Option<f64>,
top_p: Option<f64>,
enabled_tools: Option<Vec<String>>,
enabled_mcp_servers: Option<Vec<String>>,
) {
self.set_model(model.clone());
if reasoning_effort.is_some() {
self.set_reasoning_effort(reasoning_effort.clone());
}
if temperature.is_some() {
self.set_temperature(temperature);
}
@@ -410,6 +426,10 @@ impl RoleLike for Role {
self.top_p
}
fn reasoning_effort(&self) -> Option<String> {
self.reasoning_effort.clone()
}
fn enabled_tools(&self) -> Option<Vec<String>> {
self.enabled_tools.clone()
}
@@ -433,6 +453,10 @@ impl RoleLike for Role {
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>>) {
self.enabled_tools = value;
}
+24 -1
View File
@@ -24,6 +24,8 @@ pub struct Session {
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
@@ -261,7 +263,7 @@ impl Session {
data["messages"] = json!(self.messages);
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)
}
@@ -401,6 +403,7 @@ impl Session {
self.model_id = role.model().id();
self.temperature = role.temperature();
self.top_p = role.top_p();
self.reasoning_effort = role.reasoning_effort();
self.enabled_tools = role.enabled_tools();
self.enabled_mcp_servers = role.enabled_mcp_servers();
self.model = role.model().clone();
@@ -732,6 +735,15 @@ impl Session {
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 {
let messages = self.build_messages(input);
serde_yaml::to_string(&messages).unwrap_or_else(|_| "Unable to echo message".into())
@@ -783,6 +795,10 @@ impl RoleLike for Session {
self.top_p
}
fn reasoning_effort(&self) -> Option<String> {
self.reasoning_effort.clone()
}
fn enabled_tools(&self) -> Option<Vec<String>> {
self.enabled_tools.clone()
}
@@ -814,6 +830,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>>) {
if self.enabled_tools != value {
self.enabled_tools = value;
+5 -1
View File
@@ -117,7 +117,11 @@ impl Skill {
pub fn load(name: &str) -> Result<Self> {
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)
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
Ok(Skill::new(name, &content))
+18 -32
View File
@@ -474,7 +474,7 @@ fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value>
let description = renamed.frontmatter.description.clone().unwrap_or_default();
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) {
if let Ok(existing) = fs::read_to_string(&other_index)
&& existing.contains(&needle)
@@ -539,18 +539,12 @@ fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec<PathBuf> {
out.push(global_index);
}
match &store.workspace {
Some(WorkspaceMemory::Structured { dir, .. }) => {
let index = dir.join("MEMORY.md");
if dir.as_path() != own_dir && index.exists() {
if let Some(ws) = &store.workspace {
let index = ws.dir.join("MEMORY.md");
if ws.dir.as_path() != own_dir && index.exists() {
out.push(index);
}
}
Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => {
out.push(file.clone());
}
_ => {}
}
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> {
match &store.workspace {
Some(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()),
Some(WorkspaceMemory::Lite { workspace_root, .. }) => {
Ok(paths::workspace_memory_dir_for(workspace_root))
}
Some(ws) => Ok(ws.dir.clone()),
None => match find_git_root(cwd) {
Some(git_root) => bootstrap_workspace_memory(&git_root),
None => bail!(
@@ -652,20 +643,10 @@ fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
}
fn workspace_label(w: &WorkspaceMemory) -> Value {
match w {
WorkspaceMemory::Structured { workspace_root, .. } => json!({
"mode": "structured",
"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(),
}),
}
json!({
"root": w.workspace_root.display().to_string(),
"dir": w.dir.display().to_string(),
})
}
fn lint_memory(store: &MemoryStore) -> Result<Value> {
@@ -872,19 +853,24 @@ mod tests {
}
#[test]
fn workspace_write_dir_promotes_lite_to_structured_subdir() {
let root = temp_root("ws_lite_promote");
fn workspace_write_dir_treats_root_instructions_file_as_no_memory() {
let root = temp_root("ws_instructions_only");
let workspace = root.join("ws");
fs::create_dir_all(&workspace).unwrap();
fs::write(workspace.join("COYOTE.md"), "lite").unwrap();
fs::create_dir_all(workspace.join(".git")).unwrap();
fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
let store = MemoryStore {
global_dir: root.join("g"),
workspace: discover_workspace_memory(&workspace),
};
assert!(store.workspace.is_none(), "COYOTE.md must not be memory");
let dir = workspace_write_dir(&store, &workspace).unwrap();
assert_eq!(dir, workspace.join(".coyote").join("memory"));
assert!(
dir.join("MEMORY.md").exists(),
"bootstrap must create index"
);
let _ = fs::remove_dir_all(&root);
}
+76 -19
View File
@@ -5,6 +5,7 @@ pub(crate) mod todo;
pub(crate) mod user_interaction;
use crate::{
client::ThinkingBlock,
config::{Agent, RequestContext},
graph,
utils::*,
@@ -144,29 +145,19 @@ pub async fn eval_tool_calls(
if calls.is_empty() {
bail!("The request was aborted because an infinite loop of function calls was detected.")
}
let mut is_all_null = true;
for call in calls {
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!(
"{}",
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);
output.push(ToolResult::new(call, val));
is_all_null = false;
continue;
}
let mut result = call.eval(ctx).await?;
if result.is_null() {
result = json!("DONE");
} else {
is_all_null = false;
}
output.push(ToolResult::new(call, result));
}
if is_all_null {
output = vec![];
let result = call.eval(ctx).await?;
output.push(ToolResult::new(call, normalize_tool_result(result)));
}
if !output.is_empty() {
@@ -196,15 +187,37 @@ pub async fn eval_tool_calls(
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)]
pub struct ToolResult {
pub call: ToolCall,
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 {
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 tool_path = format!(
"{}/{binary_name}",
&paths::global_tools_dir().to_string_lossy()
paths::global_tools_dir().to_string_lossy()
);
content_template
.replace("{function_name}", binary_name)
@@ -741,7 +754,7 @@ impl Functions {
let root_dir = paths::agent_data_dir(agent_name);
let tool_path = format!(
"{}/{binary_name}",
&paths::global_tools_dir().to_string_lossy()
paths::global_tools_dir().to_string_lossy()
);
content_template
.replace("{function_name}", binary_name)
@@ -870,7 +883,7 @@ impl Functions {
let root_dir = paths::functions_dir();
let tool_path = format!(
"{}/{binary_name}",
&paths::global_tools_dir().to_string_lossy()
paths::global_tools_dir().to_string_lossy()
);
content_template
.replace("{function_name}", binary_name)
@@ -881,7 +894,7 @@ impl Functions {
let root_dir = paths::agent_data_dir(agent_name);
let tool_path = format!(
"{}/{binary_name}",
&paths::global_tools_dir().to_string_lossy()
paths::global_tools_dir().to_string_lossy()
);
content_template
.replace("{function_name}", binary_name)
@@ -1527,6 +1540,21 @@ mod tests {
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]
fn toolcall_new_sets_fields() {
let tc = ToolCall::new("my_tool".into(), json!({"x": 1}), Some("call-1".into()));
@@ -1890,4 +1918,33 @@ mod tests {
assert_eq!(result.call.name, "my_tool");
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());
}
}
+4
View File
@@ -329,6 +329,9 @@ fn build_inline_role(
if let Some(p) = node.top_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() {
role.set_enabled_tools(Some(Vec::new()));
@@ -499,6 +502,7 @@ mod tests {
model: None,
temperature: None,
top_p: None,
reasoning_effort: None,
fallback: None,
max_attempts: 1,
max_iterations: 10,
+25 -4
View File
@@ -33,7 +33,7 @@ async fn extract_via_extractor(
parent_ctx: &mut RequestContext,
is_repair: bool,
) -> Result<Value> {
let role = build_extractor_role()?;
let role = build_extractor_role(parent_ctx);
let prompt = build_extractor_prompt(raw, schema, is_repair);
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);
role.set_model(ctx.current_model().clone());
role.set_enabled_tools(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 {
@@ -107,8 +108,14 @@ fn strip_code_fences(s: &str) -> &str {
#[cfg(test)]
mod tests {
use super::*;
use crate::client::Model;
use crate::config::{AppState, WorkingMode};
use serde_json::json;
fn make_ctx() -> RequestContext {
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
}
#[test]
fn try_parse_json_accepts_plain_object() {
let v = try_parse_json(r#"{"a": 1}"#).unwrap();
@@ -181,9 +188,23 @@ mod tests {
#[test]
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_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")]
pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub global_tools: Vec<String>,
@@ -288,6 +291,9 @@ pub struct LlmNode {
#[serde(default, skip_serializing_if = "Option::is_none")]
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")]
pub fallback: Option<String>,
+2
View File
@@ -946,6 +946,7 @@ mod tests {
model: None,
temperature: None,
top_p: None,
reasoning_effort: None,
global_tools: Vec::new(),
mcp_servers: Vec::new(),
skills_enabled: None,
@@ -1048,6 +1049,7 @@ mod tests {
model: None,
temperature: None,
top_p: None,
reasoning_effort: None,
fallback: fallback.map(String::from),
max_attempts: 1,
max_iterations: 10,
+50 -7
View File
@@ -21,12 +21,13 @@ use crate::cli::Cli;
use crate::client::{
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::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
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::mcp::McpServersConfig;
use crate::render::{prompt_theme, render_error};
@@ -187,7 +188,11 @@ async fn main() -> Result<()> {
let abort_signal = create_abort_signal();
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
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(
AppState::init(
app_config,
@@ -365,6 +370,15 @@ async fn run(
if cli.no_memory {
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 {
ctx.empty_session()?;
}
@@ -377,10 +391,14 @@ async fn run(
paths::global_memory_index_path(),
"# 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 => (
env::current_dir()?.join("COYOTE.md"),
"# Workspace Memory\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n",
),
MemoryScope::Workspace => {
let cwd = env::current_dir()?;
let root = memory::find_git_root(&cwd).unwrap_or(cwd);
(
paths::workspace_memory_index_path_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() {
@@ -393,9 +411,34 @@ async fn run(
}
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());
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 {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
let info = ctx.info(app.as_ref())?;
@@ -559,7 +602,7 @@ async fn shell_execute(
match answer_char {
'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)?;
if code == 0 && app.save_shell_history {
let _ = append_to_shell_history(&shell.name, &eval_str, code);
+56 -1
View File
@@ -214,7 +214,52 @@ impl McpRegistry {
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 {
abortable_run_with_spinner(
@@ -1016,4 +1061,14 @@ mod tests {
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));
}
}
+193 -20
View File
@@ -14,6 +14,8 @@ use url::Url;
struct ProtectedResourceMetadata {
#[serde(default)]
authorization_servers: Vec<String>,
#[serde(default)]
scopes_supported: Vec<String>,
}
#[derive(Debug, Deserialize)]
@@ -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> {
let base = extract_base_url(server_url)?;
let client = Client::new();
let mut tried: Vec<String> = Vec::new();
// RFC 9728: try protected resource metadata first; it points to the auth server
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
if let Ok(resp) = client.get(&pr_url).send().await
&& resp.status().is_success()
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
&& let Some(auth_server) = pr.authorization_servers.first()
{
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
// RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose
// WWW-Authenticate challenge advertises the protected resource metadata URL.
let mut pr_urls = Vec::new();
if let Some(url) = probe_resource_metadata_url(&client, server_url).await {
pr_urls.push(url);
}
// RFC 9728 @ 3.1: path-aware well-known URL, then root as legacy fallback.
pr_urls.extend(well_known_urls(server_url, "oauth-protected-resource")?);
pr_urls.dedup();
for pr_url in &pr_urls {
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(meta) = resp.json::<OAuthServerMetadata>().await
&& 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");
let resp = client
.get(&as_url)
.send()
.await
.with_context(|| format!("Failed to reach {as_url}"))?;
if resp.status().is_success() {
// Last resort: the MCP server itself may host authorization server metadata.
for as_url in well_known_urls(server_url, "oauth-authorization-server")? {
tried.push(as_url.clone());
if let Ok(resp) = client.get(&as_url).send().await
&& resp.status().is_success()
{
return resp
.json::<OAuthServerMetadata>()
.await
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
}
}
Err(anyhow!(
"Could not discover OAuth metadata for '{server_url}'.\n\
Tried:\n {pr_url}\n {as_url}\n\
Ensure the server supports MCP OAuth discovery, or consult its documentation."
Tried:\n {}\n\
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> {
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
let scheme = parsed.scheme();
@@ -296,6 +374,101 @@ mod tests {
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]
#[serial]
fn registered_client_id_roundtrip() {
+4 -5
View File
@@ -358,17 +358,16 @@ mod tests {
use super::*;
use crate::function::JsonSchema;
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(
source: &str,
file_name: &str,
parent: &Path,
) -> Result<Vec<FunctionDeclaration>> {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
let unique = PARSE_COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("coyote_python_parser_{file_name}_{unique}.py"));
fs::write(&path, source).expect("failed to write temp python source");
+642 -20
View File
@@ -2,12 +2,21 @@ use super::DocumentId;
use crate::client::*;
use anyhow::{Context, Result};
use indexmap::IndexMap;
use indexmap::{IndexMap, IndexSet};
use petgraph::Direction;
use petgraph::graph::NodeIndex;
use petgraph::stable_graph::StableGraph;
use petgraph::visit::EdgeRef;
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.
@@ -89,16 +98,27 @@ impl Default for KnowledgeGraph {
impl KnowledgeGraph {
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 {
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 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
} else {
let entity = Entity {
name: extracted.name.clone(),
entity_type: extracted.entity_type.clone(),
entity_type: normalized_type,
description: extracted.description.clone(),
};
let idx = self.graph.add_node(entity);
@@ -106,7 +126,7 @@ impl KnowledgeGraph {
self.entity_index.insert(key, raw);
raw
};
chunk_nodes.push(node_raw);
chunk_nodes.insert(node_raw);
}
for extracted in &result.relationships {
@@ -118,11 +138,14 @@ impl KnowledgeGraph {
) {
let from_idx = NodeIndex::new(from_raw as usize);
let to_idx = NodeIndex::new(to_raw as usize);
// Avoid duplicate edges
if !self.graph.contains_edge(from_idx, to_idx) {
let already_exists = self
.graph
.edges_connecting(from_idx, to_idx)
.any(|e| e.weight().relation_type == extracted.relation_type);
if !already_exists {
let rel = Relationship {
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);
}
@@ -158,6 +181,10 @@ impl KnowledgeGraph {
.filter(|raw| !still_used.contains(raw))
.collect();
if to_remove.is_empty() {
return;
}
for raw in to_remove {
let idx = NodeIndex::new(raw as usize);
if self.graph.contains_node(idx) {
@@ -166,6 +193,57 @@ impl KnowledgeGraph {
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>> {
@@ -179,30 +257,79 @@ impl KnowledgeGraph {
map
}
pub fn expand_neighbors(&self, seed_nodes: &[u32], hops: usize) -> Vec<u32> {
let mut expanded: indexmap::IndexSet<u32> = seed_nodes.iter().copied().collect();
let mut frontier: Vec<u32> = seed_nodes.to_vec();
/// BFS from seed nodes with weight-decayed scoring.
///
/// 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 {
let mut next_frontier: Vec<u32> = vec![];
for &raw in &frontier {
let idx = NodeIndex::new(raw as usize);
if self.graph.contains_node(idx) {
if node_scores.len() >= MAX_GRAPH_NODES {
break;
}
frontier.sort_unstable_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
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 neighbor in self.graph.neighbors_directed(idx, dir) {
let n = neighbor.index() as u32;
if expanded.insert(n) {
next_frontier.push(n);
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;
if frontier.is_empty() {
break;
}
}
expanded.into_iter().collect()
node_scores
}
}
@@ -213,6 +340,14 @@ pub async fn extract_entities(
chunk: &str,
prompt_template: Option<&str>,
) -> 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 prompt = template.replace("__CHUNK__", chunk);
let mut messages = vec![Message::new(
@@ -227,6 +362,7 @@ pub async fn extract_entities(
messages,
temperature: Some(0.0),
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
};
@@ -250,3 +386,489 @@ pub async fn extract_entities(
serde_json::from_str::<ExtractionResult>(&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"
);
}
}
+137 -40
View File
@@ -25,6 +25,8 @@ use std::{
};
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)
<context>
@@ -752,14 +754,14 @@ impl Rag {
bail!("No RAG files");
}
if self.data.extractor_model.is_some()
&& !new_doc_contents.is_empty()
if !new_doc_contents.is_empty()
&& let Some(extractor_model_id) = self.data.extractor_model.clone()
{
match Model::retrieve_model(&self.app_config, &extractor_model_id, ModelType::Chat) {
Ok(model) => match self.create_embeddings_client(model) {
Ok(client) => {
let total = new_doc_contents.len();
let mut failures = 0usize;
for (i, (doc_id, content)) in new_doc_contents.into_iter().enumerate() {
progress(
&spinner,
@@ -774,14 +776,21 @@ impl Rag {
{
Ok(result) => self.data.knowledge_graph.merge(doc_id, result),
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() {
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
.iter()
.filter(|(name, _)| {
@@ -946,52 +977,31 @@ impl Rag {
.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();
if seed_nodes.is_empty() {
if seed_scores.is_empty() {
let bm25_results = self.bm25.search(query, top_k * 2);
'outer: for result in bm25_results {
if let Some(node_raws) = kg.document_entities.get(&result.document.id.0) {
seed_nodes.extend(node_raws.iter().copied());
if seed_nodes.len() >= top_k {
for &raw in node_raws {
seed_scores.push((raw, BM25_SEED_SCORE));
if seed_scores.len() >= top_k {
break 'outer;
}
}
}
}
}
if seed_nodes.is_empty() {
if seed_scores.is_empty() {
return vec![];
}
let hops = self.data.graph_hops.unwrap_or(1);
let expanded = kg.expand_neighbors(&seed_nodes, 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
let mut scored: Vec<(u32, f32)> = kg
.expand_neighbors_scored(&seed_scores, hops)
.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();
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> {
let value = Text::new("Set graph expansion hops:")
.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| {
let out = match text.parse::<usize>() {
Ok(v) if v >= 1 => Validation::Valid,
_ => Validation::Invalid("Must be an integer >= 1".into()),
Ok(_) => Validation::Valid,
_ => Validation::Invalid("Must be a non-negative integer".into()),
};
Ok(out)
})
@@ -1771,4 +1781,91 @@ mod tests {
assert_eq!(file_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"
);
}
}
+13 -5
View File
@@ -2,7 +2,7 @@ use super::{MarkdownRender, SseEvent};
use crate::utils::{AbortSignal, poll_abort_signal, spawn_spinner};
use anyhow::{Error, Result};
use anyhow::Result;
use crossterm::{
cursor, queue, style,
terminal::{self, disable_raw_mode, enable_raw_mode},
@@ -74,6 +74,8 @@ async fn markdown_stream_inner(
let mut buffer_rows = 1;
let columns = terminal::size()?.0;
let mut last_col: u16 = 0;
let mut last_row: u16 = 0;
let mut spinner = Some(spawn_spinner("Generating"));
@@ -94,9 +96,16 @@ async fn markdown_stream_inner(
let mut attempts = 0;
let (col, mut row) = loop {
match cursor::position() {
Ok(pos) => break pos,
Err(_) if attempts < 3 => attempts += 1,
Err(e) => return Err(Error::from(e)),
Ok(pos) => {
last_col = pos.0;
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),
}
};
@@ -142,7 +151,6 @@ async fn markdown_stream_inner(
queue!(writer, style::Print(&output))?;
buffer_rows = need_rows(&output, columns);
}
writer.flush()?;
}
SseEvent::Done => {
+2
View File
@@ -31,6 +31,7 @@ impl Completer for ReplCompleter {
let ctx = self.ctx.read();
let state = ctx.state();
let model_has_reasoning = !ctx.current_model().reasoning_levels().is_empty();
let command_filter = parts
.iter()
@@ -44,6 +45,7 @@ impl Completer for ReplCompleter {
.filter(|cmd| {
cmd.is_valid(state)
&& (command_filter.len() == 1 || cmd.name.starts_with(&command_filter[..2]))
&& (cmd.name != ".reasoning" || model_has_reasoning)
})
.collect();
let commands = fuzzy_filter(commands, |v| v.name, &command_filter);
+166 -4
View File
@@ -52,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
4. Continue with the next pending item now. Call tools immediately."
};
static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
static REPL_COMMANDS: LazyLock<[ReplCommand; 57]> = LazyLock::new(|| {
[
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -71,6 +71,26 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
"Authenticate with an MCP server via OAuth",
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(
".edit config",
"Modify configuration file",
@@ -125,6 +145,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
"Clear session messages",
AssertState::True(StateFlags::SESSION),
),
ReplCommand::new(
".undo",
"Undo the last exchange and restore the prompt",
AssertState::True(StateFlags::SESSION),
),
ReplCommand::new(
".compress session",
"Compress session messages",
@@ -254,11 +279,21 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
),
ReplCommand::new(".copy", "Copy last response", 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(
".delete",
"Delete roles, sessions, RAGs, or agents",
AssertState::pass(),
),
ReplCommand::new(
".list",
"List roles, sessions, agents, RAGs, macros, skills, tools, or MCP servers",
AssertState::pass(),
),
ReplCommand::new(
".vault",
"View or modify the Coyote vault",
@@ -390,6 +425,10 @@ Type ".help" for additional help.
if exit {
break;
}
if let Some(text) = self.ctx.write().pending_prefill.take() {
self.editor
.run_edit_commands(&[EditCommand::InsertString(text)]);
}
}
Err(err) => {
render_error(err);
@@ -659,14 +698,69 @@ pub async fn run_repl_command(
)
.await?;
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()?,
}
}
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 {
Some(text) => {
@@ -853,6 +947,46 @@ pub async fn run_repl_command(
let app = Arc::clone(&ctx.app.config);
ctx.use_agent(app.as_ref(), agent_name, session_name, abort_signal.clone())
.await?;
if let Some(session) = &ctx.session {
let messages_snapshot: Vec<Message> = session
.messages()
.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!();
}
}
}
None => {
println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#)
@@ -966,6 +1100,15 @@ pub async fn run_repl_command(
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 {
Some("rag") => {
ctx.rebuild_rag(abort_signal.clone()).await?;
@@ -1052,6 +1195,15 @@ pub async fn run_repl_command(
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 {
Some(args) => {
ctx.delete(args)?;
@@ -1060,6 +1212,16 @@ pub async fn run_repl_command(
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" => {
let output = match ctx
.last_message
@@ -1582,8 +1744,8 @@ mod tests {
}
#[test]
fn repl_commands_has_50_entries() {
assert_eq!(REPL_COMMANDS.len(), 50);
fn repl_commands_has_57_entries() {
assert_eq!(REPL_COMMANDS.len(), 57);
}
#[test]