Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3a818507
|
||
|
|
63f73f22c3
|
||
|
|
54c5079cb7
|
||
|
|
d51bdd3086
|
||
|
|
56ec58a748
|
||
|
|
93f9c5425e
|
||
|
|
77dfd08103 | ||
|
|
46dcef0dec | ||
|
|
72c6bb74c2 | ||
|
|
6bf80dcce9
|
||
|
|
df948c69bf
|
||
|
|
e5d0fcc764
|
||
|
|
c60d3e7cda
|
||
|
|
c21fd47b42
|
||
|
|
ab0b89dd67
|
||
|
|
420db4bb88
|
||
|
|
dfacf31f6a
|
||
|
|
8cfd5ee2c4
|
||
|
|
e82e5ab8e4
|
||
|
|
d790782ace
|
||
|
|
bf06d5e8f3
|
||
|
|
cdfaa0f111
|
||
|
|
c062f34852
|
||
|
|
7671d28d6e
|
||
|
|
fcc4a1d2b5
|
||
|
|
b0eeba110d
|
||
|
|
d65d63ee50
|
||
|
|
9890cf0ddc
|
||
|
|
89db5b3887
|
||
|
|
f40ba4ccbe
|
||
|
|
d2940a8d32
|
||
|
|
ed7ad36475
|
||
|
|
393ed16963
|
||
|
|
3f94d2003a
|
||
|
|
50ff9008fe
|
||
|
|
9606d7f8aa
|
||
|
|
d8ae9e25d9
|
||
|
|
006f64bfa0
|
||
|
|
000559bc9d
|
||
|
|
3aede58a11
|
||
|
|
cab1e72b97
|
||
|
|
cd4bf245e9
|
||
|
|
82bf6176f8
|
||
|
|
d407eb5a6a
|
||
|
|
6f2594712f
|
||
|
|
79d43c8791
|
||
|
|
5a5da90734
|
||
|
|
f2a0e7453e
|
||
|
|
0fe430102a
|
||
|
|
d13bd32fdf
|
||
|
|
1f1729ba00
|
||
|
|
107419966d
|
||
|
|
344ef7526f
|
||
|
|
13d31f850c
|
||
|
|
f6bd02dc73
|
||
|
|
31df1a720d
|
||
|
|
4e0e65fc8a
|
||
|
|
ab85a4f534
|
||
|
|
420447275c
|
||
|
|
cdc40f7302
|
||
|
|
c611685033
|
||
|
|
cac2a3eba0
|
||
|
|
66bbb34d7f
|
||
|
|
68177fdb6a
|
||
|
|
1acaad223f
|
||
|
|
aa0270602d
|
||
|
|
4669958bdd
|
||
|
|
559107073d
|
||
|
|
d0a38747e0
|
||
|
|
677bd71b93 | ||
|
|
ad6d0a2e0e
|
||
|
|
50911b99ef
|
||
|
|
44783c5573
|
||
|
|
8629c1ca15
|
||
|
|
078e6e3744
|
||
|
|
b908fc20ba
|
||
|
|
058810137c
|
||
|
|
c979041161
|
||
|
|
a606ea552d
|
||
|
|
39a654a79e
|
||
|
|
17d1decce6
|
||
|
|
a45e66c634
|
||
|
|
8c885d9a77
|
||
|
|
6dd1e59815
|
||
|
|
f5085a773a
|
||
|
|
09afdeaf7c
|
||
|
|
0216d84eee
|
||
|
|
320dbf2479
|
||
|
|
6958e9cba8
|
||
|
|
825f9f6bf5
|
||
|
|
863740f916
|
||
|
|
304088bf5c
|
||
|
|
b7599b8acf
|
||
|
|
9b3ae761f3
|
||
|
|
0f7877aafc
|
||
|
|
5843a9ac15
|
||
|
|
4bfaabcb99
|
||
|
|
f16f858074
|
||
|
|
e9a8c01dc4
|
||
|
|
5bbf1b2d71 | ||
|
|
e9c52566b8
|
||
|
|
4c7de650c0
|
||
|
|
6127d964ee
|
||
|
|
8bbbd71fec
|
||
|
|
7f89a80f7e
|
||
|
|
19cca06db6
|
||
|
|
e8df9f119c
|
||
|
|
8abe297bfe
|
||
|
|
4ec6daff30
|
||
|
|
9c1067e544
|
||
|
|
2fe6704fbc | ||
|
|
dd40892ad5 | ||
|
|
ed86b7bfc3 | ||
|
|
f32d72a3f2 | ||
|
|
7b00638476 | ||
|
|
6733b3600f
|
||
|
|
de6010d525
|
||
|
|
9b0e26bade
|
||
|
|
ac40043c00
|
||
|
|
d8eec1d427
|
||
|
|
382916c3ee
|
||
|
|
bc3cc10a7b
|
||
|
|
b91f738209
|
||
|
|
4f0dae9b49
|
||
|
|
deb673ebc9
|
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
Generated
+84
@@ -80,6 +80,15 @@ dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansi-str"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d"
|
||||
dependencies = [
|
||||
"ansitok",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansi_colours"
|
||||
version = "1.2.3"
|
||||
@@ -89,6 +98,16 @@ dependencies = [
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansitok"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -219,6 +238,12 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
@@ -1016,6 +1041,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.0"
|
||||
@@ -1281,6 +1312,19 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "comfy-table"
|
||||
version = "7.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
||||
dependencies = [
|
||||
"ansi-str",
|
||||
"console",
|
||||
"crossterm",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.38"
|
||||
@@ -1429,6 +1473,7 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"clap_complete_nushell",
|
||||
"colored",
|
||||
"comfy-table",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"duct",
|
||||
@@ -1457,6 +1502,7 @@ dependencies = [
|
||||
"path-absolutize",
|
||||
"petgraph 0.7.1",
|
||||
"pretty_assertions",
|
||||
"qrcode",
|
||||
"rand 0.10.1",
|
||||
"reedline",
|
||||
"reqwest 0.13.4",
|
||||
@@ -3046,6 +3092,18 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -3576,6 +3634,16 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399"
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -4418,6 +4486,21 @@ dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||
dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -6613,6 +6696,7 @@ version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ exclude = [".github", "CONTRIBUTING.md"]
|
||||
anyhow = "1.0.69"
|
||||
bytes = "1.4.0"
|
||||
clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] }
|
||||
comfy-table = { version = "7.2.2", features = ["custom_styling"] }
|
||||
dirs = "6.0.0"
|
||||
dunce = "1.0.5"
|
||||
futures-util = "0.3.29"
|
||||
@@ -107,6 +108,7 @@ self_update = { version = "0.44", default-features = false, features = [
|
||||
"archive-zip",
|
||||
"compression-zip-deflate",
|
||||
] }
|
||||
qrcode = "0.14"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.13.3"
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
ARG COYOTE_VERSION
|
||||
FROM docker/sandbox-templates:shell-docker
|
||||
|
||||
ARG COYOTE_VERSION
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV PATH="/home/agent/.cargo/bin:/home/agent/.local/bin:${PATH}"
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
jq curl git \
|
||||
build-essential pkg-config \
|
||||
cmake \
|
||||
clang libclang-dev \
|
||||
musl-tools \
|
||||
libssl-dev \
|
||||
pandoc \
|
||||
bzip2 \
|
||||
nano && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN set -euo pipefail; \
|
||||
USQL_VERSION=0.21.4; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) USQL_ARCH=amd64 ;; \
|
||||
arm64) USQL_ARCH=arm64 ;; \
|
||||
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
TMPDIR=$(mktemp -d); \
|
||||
curl -fsSL --retry 3 \
|
||||
"https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" \
|
||||
-o "$TMPDIR/usql.tar.bz2"; \
|
||||
tar -xjf "$TMPDIR/usql.tar.bz2" -C "$TMPDIR"; \
|
||||
install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql; \
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
USER 1000
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
printf '#!/bin/sh\nexec uv tool run "$@"\n' > "$HOME/.local/bin/uvx" && \
|
||||
chmod +x "$HOME/.local/bin/uvx"
|
||||
|
||||
RUN mkdir -p /usr/local/share/npm-global/lib
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --default-toolchain stable --profile minimal && \
|
||||
. "$HOME/.cargo/env" && \
|
||||
cargo install --locked iwec && \
|
||||
cargo install --locked ast-grep
|
||||
|
||||
USER root
|
||||
|
||||
RUN set -euo pipefail; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) MUSL_TARGET=x86_64-unknown-linux-musl ;; \
|
||||
arm64) MUSL_TARGET=aarch64-unknown-linux-musl ;; \
|
||||
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
TMPDIR=$(mktemp -d); \
|
||||
curl -fsSL --retry 3 \
|
||||
"https://github.com/Dark-Alex-17/coyote/releases/download/v${COYOTE_VERSION}/coyote-${MUSL_TARGET}.tar.gz" \
|
||||
-o "$TMPDIR/coyote.tar.gz"; \
|
||||
tar -xzf "$TMPDIR/coyote.tar.gz" -C "$TMPDIR"; \
|
||||
install -m 0755 "$TMPDIR/coyote" /home/agent/.cargo/bin/coyote; \
|
||||
chown 1000:1000 /home/agent/.cargo/bin/coyote; \
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
USER 1000
|
||||
|
||||
ENTRYPOINT ["coyote"]
|
||||
@@ -5,6 +5,7 @@
|
||||

|
||||

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

|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Adversary
|
||||
|
||||
An **adversarial plan-conformance reviewer**. Where [`code-reviewer`](../code-reviewer/README.md)
|
||||
asks *"is this code good?"*, `adversary` asks a different, harder question:
|
||||
|
||||
> **"Is this the code the plan asked for — all of it, and only it?"**
|
||||
|
||||
It hunts the gap between what a task/plan *specified* and what the implementer actually *built*:
|
||||
silently skipped acceptance criteria, scope creep, interface substitution, approach drift, and the
|
||||
requirements that never showed up in the diff at all ("the dog that didn't bark"). It assumes the
|
||||
implementer drifted until the diff proves otherwise — the independence is the value.
|
||||
|
||||
## Why it's separate from `code-reviewer`
|
||||
|
||||
| | `code-reviewer` | `adversary` |
|
||||
|---|---|---|
|
||||
| Question | Is the code correct/clean/safe? | Does the code match the plan? |
|
||||
| Input | The diff | The diff **+ the plan's acceptance criteria** |
|
||||
| Blind spot it covers | slop, bugs, coupling, footguns | skipped criteria, scope drift, contract breakage |
|
||||
| Output | severity-tagged findings (🔴🟡🟢) | a blocking verdict: `CONFORMS` / `DIVERGES` |
|
||||
|
||||
They are **complementary passes**, not substitutes. `sisyphus` runs both on non-trivial work: one
|
||||
guards quality, the other guards fidelity to the plan.
|
||||
|
||||
## Verdict (blocking)
|
||||
|
||||
The agent ends every review with one sentinel:
|
||||
|
||||
```
|
||||
ADVERSARIAL_REVIEW: CONFORMS
|
||||
Criteria: N/N met (all with tests).
|
||||
```
|
||||
|
||||
```
|
||||
ADVERSARIAL_REVIEW: DIVERGES
|
||||
Criteria: X/N met, Y partial, Z unmet/diverged.
|
||||
Complaints:
|
||||
1. Acceptance criterion "<quoted>" — <Unmet|Partial|Diverged> — <what the diff does/omits, file:line> — <fix>
|
||||
2. ...
|
||||
```
|
||||
|
||||
A `DIVERGES` verdict **blocks** completion. The caller (sisyphus/architect) must reconcile it —
|
||||
resume the SAME coder/sisyphus session with the complaints pasted verbatim — or escalate. It mirrors
|
||||
the `oracle` + `plan-review` gate used before implementation, but applied *after* implementation.
|
||||
|
||||
Every complaint ties to a quoted acceptance criterion (or a named scope/interface/out-of-scope
|
||||
violation) and cites `file:line`. Vague complaints are not emitted.
|
||||
|
||||
## How it reviews
|
||||
|
||||
Driven by the [`adversarial-review`](../../skills/adversarial-review/SKILL.md) skill:
|
||||
|
||||
1. Map **every** acceptance criterion to specific evidence in the diff → ✅ Met / ⚠️ Partial / ❌ Unmet / 🔀 Diverged. No test proving the behavior ⇒ at best ⚠️ Partial.
|
||||
2. Ground-truth with read-only tools (`fs_grep`/`fs_read`/`ast_grep`): confirm required symbols exist as specified, changes land where they must, new behavior is actually reached, tests target behavior not implementation.
|
||||
3. Hunt adversarially for the **absent**: skipped criteria, scope creep, interface/approach substitution, out-of-scope touches, downstream contract breakage.
|
||||
|
||||
It is **read-only** — it produces a verdict, never a fix.
|
||||
|
||||
## Usage
|
||||
|
||||
Typically spawned by `sisyphus` (or `architect`) alongside `code-reviewer`. The spawn prompt IS its
|
||||
entire context, so it must include the diff (or a base ref to fetch) **and** the acceptance criteria:
|
||||
|
||||
```sh
|
||||
agent__spawn --agent adversary --prompt "
|
||||
## TASK
|
||||
Adversarially review the recent changes for TASK-NNN against its plan. Return CONFORMS/DIVERGES.
|
||||
|
||||
## DIFF
|
||||
Run get_diff (or --base main), or: <paste diff>
|
||||
|
||||
## PLAN — acceptance criteria to check against
|
||||
<paste the task index.md body + the relevant PLAN-*.md section, verbatim>
|
||||
"
|
||||
```
|
||||
|
||||
Direct invocation for ad-hoc use:
|
||||
|
||||
```sh
|
||||
coyote -a adversary --agent-variable project_dir /path/to/repo \
|
||||
"Review staged changes against these criteria: <paste criteria>"
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
- `get_diff [--base <ref>]` — staged → unstaged → `HEAD~1` fallback (or an explicit base/PR branch).
|
||||
- `get_changed_files [--base <ref>]` — quick changed-file map.
|
||||
- Plus read-only `fs_*` and `ast_grep` for ground-truth checks.
|
||||
|
||||
## Related
|
||||
|
||||
- [`adversarial-review`](../../skills/adversarial-review/SKILL.md) — the conformance methodology it runs on.
|
||||
- [`code-reviewer`](../code-reviewer/README.md) — the quality reviewer it runs alongside.
|
||||
- [`plan-review`](../../skills/plan-review/SKILL.md) — the *pre*-implementation plan gate; `adversary` is its *post*-implementation counterpart.
|
||||
@@ -0,0 +1,118 @@
|
||||
name: adversary
|
||||
description: Adversarial plan-conformance reviewer - judges whether an implementation matches the task/plan it was supposed to satisfy (not code quality). Returns a blocking CONFORMS/DIVERGES verdict. Complements code-reviewer. Designed to be delegated to by sisyphus.
|
||||
version: 1.0.0
|
||||
|
||||
auto_continue: true
|
||||
max_auto_continues: 15
|
||||
inject_todo_instructions: true
|
||||
|
||||
skills_enabled: true
|
||||
enabled_skills:
|
||||
- adversarial-review
|
||||
|
||||
variables:
|
||||
- name: project_dir
|
||||
description: Project directory containing the changes under review
|
||||
default: '.'
|
||||
- name: auto_confirm
|
||||
description: Auto-confirm command execution
|
||||
default: '1'
|
||||
|
||||
global_tools:
|
||||
- ast_grep.sh
|
||||
- fs_read.sh
|
||||
- fs_cat.sh
|
||||
- fs_grep.sh
|
||||
- fs_glob.sh
|
||||
- fs_ls.sh
|
||||
- execute_command.sh
|
||||
|
||||
instructions: |
|
||||
You are an adversarial plan-conformance reviewer. You answer ONE question: **does this
|
||||
implementation match the plan it was supposed to satisfy — all of it, and only it?** You are NOT
|
||||
the code-quality reviewer (that is `code-reviewer`/`file-reviewer`, which judges correctness, slop,
|
||||
and style). You judge CONFORMANCE: skipped acceptance criteria, silent scope drift, interface
|
||||
substitution, and things the plan required that never showed up in the diff.
|
||||
|
||||
Your value is independence and suspicion. Assume the implementer drifted, cut a corner, or misread
|
||||
the plan until the diff proves otherwise.
|
||||
|
||||
## Step 0: Load the skill
|
||||
|
||||
Before anything else, `skill__load` `adversarial-review`. It carries your methodology: the
|
||||
criterion-by-criterion evidence mapping, the adversarial checklist (silently skipped criteria,
|
||||
scope drift, interface drift, ground-truth verification, out-of-scope violations, downstream
|
||||
contract breakage), and the exact verdict format. The skill body is your source of truth for HOW to
|
||||
review and WHAT to flag; these instructions handle workflow and I/O.
|
||||
|
||||
## Input (the spawn prompt IS your entire context)
|
||||
|
||||
You are given:
|
||||
1. **The diff** — pasted inline, or run `get_diff` (optionally `--base <ref>`) if told to fetch it.
|
||||
2. **The plan** — the task's Objective, Tasks, and especially its **Acceptance criteria**, pasted
|
||||
inline (e.g. a BCP task `index.md` body + the relevant `PLAN-*.md` section), or a path to read.
|
||||
|
||||
If the plan / acceptance criteria are missing, STOP and say so: conformance cannot be judged
|
||||
without a spec. Do not invent criteria or guess intent.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Load `adversarial-review`.
|
||||
2. Get the diff (inline or via `get_diff`) and identify the changed files.
|
||||
3. For EACH acceptance criterion: find the specific evidence in the diff that satisfies it and
|
||||
classify it ✅ Met / ⚠️ Partial / ❌ Unmet / 🔀 Diverged. A criterion with no test proving its
|
||||
behavior is at best ⚠️ Partial.
|
||||
4. Ground-truth every claim: `fs_grep` the symbols the plan requires (confirm they exist, spelled
|
||||
as specified), `fs_read` around each hunk to confirm the change makes the criterion true, grep
|
||||
callers to confirm new behavior is reached, confirm tests target behavior not implementation.
|
||||
Use `ast_grep` for structural checks (e.g. "was this function signature actually changed?").
|
||||
5. Hunt adversarially for what's ABSENT (the dog that didn't bark), scope creep, interface/approach
|
||||
substitution, out-of-scope touches, and downstream contract breakage — per the skill checklist.
|
||||
6. Emit the verdict in the skill's exact format.
|
||||
|
||||
## Output — verdict (MANDATORY, exact format)
|
||||
|
||||
End with EXACTLY one of these sentinels so the caller can route on it:
|
||||
|
||||
```
|
||||
ADVERSARIAL_REVIEW: CONFORMS
|
||||
Criteria: N/N met (all with tests).
|
||||
<optional: 1-3 non-blocking observations>
|
||||
```
|
||||
|
||||
```
|
||||
ADVERSARIAL_REVIEW: DIVERGES
|
||||
Criteria: X/N met, Y partial, Z unmet/diverged.
|
||||
Complaints:
|
||||
1. Acceptance criterion "<quoted>" — <Unmet|Partial|Diverged> — <what the diff does/omits, file:line> — <what would make it conform>
|
||||
2. Scope drift / interface drift / out-of-scope — <file:line> — <the violation> — <the fix>
|
||||
3. ...
|
||||
```
|
||||
|
||||
Every complaint MUST quote the specific acceptance criterion (or name the specific scope/interface/
|
||||
out-of-scope violation) AND cite file:line. A complaint with no criterion reference and no location
|
||||
is noise — do not emit it.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **You are read-only.** Never modify files. You produce a verdict; the implementer owns the fix.
|
||||
2. **Conformance, not quality.** Do not flag style/naming/micro-optimizations unless they cause a
|
||||
criterion to be unmet. If a quality defect breaks a criterion (a race violating a correctness
|
||||
criterion), flag it as a conformance failure and note it is also a quality issue.
|
||||
3. **No test ⇒ not met.** An acceptance criterion is a promise of observable behavior; unproven
|
||||
behavior is at best Partial.
|
||||
4. **Absence is a finding.** Review what SHOULD be in the diff per the plan, not only what IS.
|
||||
5. **Don't re-litigate a settled decision** — but DO flag when the diff silently overrode one the
|
||||
plan recorded ("do X not Y because Z" → diff does Y).
|
||||
6. **The plan can be the culprit.** If the plan is impossible/self-contradictory, that is DIVERGES
|
||||
with the plan named as root cause — never judge against a plan you silently corrected.
|
||||
7. Be terse and decisive. Three real divergences beat fifteen weak ones. If everything is a nitpick,
|
||||
it CONFORMS — say so.
|
||||
|
||||
## Context
|
||||
- Project: {{project_dir}}
|
||||
- CWD: {{__cwd__}}
|
||||
- Shell: {{__shell__}}
|
||||
|
||||
## Available Tools
|
||||
{{__tools__}}
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
# @env LLM_OUTPUT=/dev/stdout
|
||||
# @env LLM_AGENT_VAR_PROJECT_DIR=.
|
||||
# @describe Adversarial plan-conformance reviewer tools
|
||||
|
||||
_project_dir() {
|
||||
local dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
|
||||
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
|
||||
}
|
||||
|
||||
# @cmd Get the git diff to review for plan conformance. Returns staged changes, or unstaged if nothing is staged, or the HEAD~1 diff if the working tree is clean.
|
||||
# @option --base Optional base ref to diff against (e.g., "main", "HEAD~3", a commit SHA, or a PR base branch)
|
||||
get_diff() {
|
||||
local project_dir
|
||||
project_dir=$(_project_dir)
|
||||
# shellcheck disable=SC2154
|
||||
local base="${argc_base:-}"
|
||||
|
||||
local diff_output=""
|
||||
if [[ -n "${base}" ]]; then
|
||||
diff_output=$(cd "${project_dir}" && git diff "${base}" 2>&1) || true
|
||||
else
|
||||
diff_output=$(cd "${project_dir}" && git diff --cached 2>&1) || true
|
||||
if [[ -z "${diff_output}" ]]; then
|
||||
diff_output=$(cd "${project_dir}" && git diff 2>&1) || true
|
||||
fi
|
||||
if [[ -z "${diff_output}" ]]; then
|
||||
diff_output=$(cd "${project_dir}" && git diff HEAD~1 2>&1) || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${diff_output}" ]]; then
|
||||
echo "No changes found to review in ${project_dir}." >> "$LLM_OUTPUT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local file_count
|
||||
file_count=$(echo "${diff_output}" | grep -c '^diff --git' || true)
|
||||
{
|
||||
echo "Diff contains changes to ${file_count} file(s):"
|
||||
echo ""
|
||||
echo "${diff_output}"
|
||||
} >> "$LLM_OUTPUT"
|
||||
}
|
||||
|
||||
# @cmd Get the list of changed files with stats (a quick map of what to check against the plan).
|
||||
# @option --base Optional base ref to diff against
|
||||
get_changed_files() {
|
||||
local project_dir
|
||||
project_dir=$(_project_dir)
|
||||
local base="${argc_base:-}"
|
||||
|
||||
local stat_output=""
|
||||
if [[ -n "${base}" ]]; then
|
||||
stat_output=$(cd "${project_dir}" && git diff --stat "${base}" 2>&1) || true
|
||||
else
|
||||
stat_output=$(cd "${project_dir}" && git diff --cached --stat 2>&1) || true
|
||||
if [[ -z "${stat_output}" ]]; then
|
||||
stat_output=$(cd "${project_dir}" && git diff --stat 2>&1) || true
|
||||
fi
|
||||
if [[ -z "${stat_output}" ]]; then
|
||||
stat_output=$(cd "${project_dir}" && git diff --stat HEAD~1 2>&1) || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${stat_output}" ]]; then
|
||||
echo "No changes found in ${project_dir}." >> "$LLM_OUTPUT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "Changed files:"
|
||||
echo ""
|
||||
echo "${stat_output}"
|
||||
} >> "$LLM_OUTPUT"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -19,8 +19,12 @@ variables:
|
||||
- name: project_dir
|
||||
description: Project directory to review
|
||||
default: '.'
|
||||
- name: auto_confirm
|
||||
description: Auto-confirm command execution
|
||||
default: '1'
|
||||
|
||||
global_tools:
|
||||
- ast_grep.sh
|
||||
- fs_read.sh
|
||||
- fs_cat.sh
|
||||
- fs_grep.sh
|
||||
|
||||
@@ -2,9 +2,9 @@ name: coder
|
||||
description: |
|
||||
Implementation agent. Plans, implements, and runs build + tests in a
|
||||
bounded fix-loop until verified. Designed to be delegated to by sisyphus.
|
||||
version: "1.0"
|
||||
|
||||
version: '1.0'
|
||||
global_tools:
|
||||
- ast_grep.sh
|
||||
- fs_cat.sh
|
||||
- fs_ls.sh
|
||||
- fs_write.sh
|
||||
@@ -25,7 +25,7 @@ variables:
|
||||
Absolute path to the project directory. Defaults to "." which is the
|
||||
directory you invoked `coyote` from. Override at runtime with
|
||||
`coyote -a coder --agent-variable project_dir /abs/path "..."`.
|
||||
default: "."
|
||||
default: '.'
|
||||
|
||||
settings:
|
||||
max_loop_iterations: 20
|
||||
@@ -34,14 +34,14 @@ settings:
|
||||
timeout: 1800
|
||||
|
||||
initial_state:
|
||||
project_dir: ""
|
||||
project_dir: ''
|
||||
fix_attempts: 0
|
||||
max_fix_attempts: 3
|
||||
fix_instructions: ""
|
||||
build_output: ""
|
||||
tests_output: ""
|
||||
last_node_output: ""
|
||||
plan_summary: ""
|
||||
fix_instructions: ''
|
||||
build_output: ''
|
||||
tests_output: ''
|
||||
last_node_output: ''
|
||||
plan_summary: ''
|
||||
files_to_modify: []
|
||||
files_to_create: []
|
||||
risks: []
|
||||
@@ -49,7 +49,7 @@ initial_state:
|
||||
review_attempts: 0
|
||||
max_review_attempts: 1
|
||||
review_clean: true
|
||||
review_notes: ""
|
||||
review_notes: ''
|
||||
|
||||
start: resolve_paths
|
||||
|
||||
@@ -88,7 +88,7 @@ nodes:
|
||||
etc. Empty list is fine.
|
||||
|
||||
Project directory: {{project_dir}}
|
||||
prompt: "{{initial_prompt}}"
|
||||
prompt: '{{initial_prompt}}'
|
||||
tools: []
|
||||
output_schema:
|
||||
type: object
|
||||
@@ -98,20 +98,27 @@ nodes:
|
||||
description: 1-3 sentences summarizing what will be done
|
||||
files_to_modify:
|
||||
type: array
|
||||
items: {type: string}
|
||||
items: { type: string }
|
||||
files_to_create:
|
||||
type: array
|
||||
items: {type: string}
|
||||
items: { type: string }
|
||||
complexity_score:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
risks:
|
||||
type: array
|
||||
items: {type: string}
|
||||
required: [plan_summary, files_to_modify, files_to_create, complexity_score, risks]
|
||||
items: { type: string }
|
||||
required:
|
||||
[
|
||||
plan_summary,
|
||||
files_to_modify,
|
||||
files_to_create,
|
||||
complexity_score,
|
||||
risks,
|
||||
]
|
||||
state_updates:
|
||||
last_node_output: "{{output}}"
|
||||
last_node_output: '{{output}}'
|
||||
fallback: end_failure
|
||||
next: route_complexity
|
||||
|
||||
@@ -144,11 +151,11 @@ nodes:
|
||||
|
||||
Approve this plan?
|
||||
options:
|
||||
- "yes"
|
||||
- "no"
|
||||
- 'yes'
|
||||
- 'no'
|
||||
routes:
|
||||
"yes": implement
|
||||
"no": end_rejected
|
||||
'yes': implement
|
||||
'no': end_rejected
|
||||
on_other: end_rejected
|
||||
|
||||
implement:
|
||||
@@ -243,7 +250,7 @@ nodes:
|
||||
- execute_command
|
||||
max_iterations: 30
|
||||
state_updates:
|
||||
last_node_output: "{{output}}"
|
||||
last_node_output: '{{output}}'
|
||||
fallback: end_failure
|
||||
next: verify_build
|
||||
|
||||
@@ -326,7 +333,7 @@ nodes:
|
||||
description: Concrete issues found, one per line as file:line - description. Empty when review_clean is true.
|
||||
required: [review_clean, review_notes]
|
||||
state_updates:
|
||||
last_node_output: "{{output}}"
|
||||
last_node_output: '{{output}}'
|
||||
fallback: end_success
|
||||
next: route_review_result
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: explore
|
||||
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out 2-5 in parallel by orchestrators.
|
||||
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out in parallel by orchestrators — scale to the number of distinct search angles the task requires.
|
||||
version: 3.1.0
|
||||
|
||||
skills_enabled: true
|
||||
@@ -10,16 +10,19 @@ variables:
|
||||
- name: project_dir
|
||||
description: Project directory to explore
|
||||
default: '.'
|
||||
- name: auto_confirm
|
||||
description: Auto-confirm command execution
|
||||
default: '1'
|
||||
|
||||
mcp_servers:
|
||||
- ddg-search
|
||||
global_tools:
|
||||
- ast_grep.sh
|
||||
- fs_read.sh
|
||||
- fs_cat.sh
|
||||
- fs_grep.sh
|
||||
- fs_glob.sh
|
||||
- fs_ls.sh
|
||||
- ast_grep.sh
|
||||
|
||||
instructions: |
|
||||
You are a codebase explorer. Your job: Search, find, report. Nothing else.
|
||||
@@ -34,7 +37,7 @@ instructions: |
|
||||
|
||||
## You may be one of many parallel explorers
|
||||
|
||||
Orchestrators (like Sisyphus) often fan out 2-5 explore agents at once, each covering a different angle of the same question. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling.
|
||||
Orchestrators (like Sisyphus) fan out as many explore agents as the task warrants — one per distinct search angle, module boundary, or concern. You may be one of many running in parallel. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling.
|
||||
|
||||
If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,9 @@ variables:
|
||||
- name: project_dir
|
||||
description: Project directory for context
|
||||
default: '.'
|
||||
- name: auto_confirm
|
||||
description: Auto-confirm command execution
|
||||
default: '1'
|
||||
|
||||
global_tools:
|
||||
- fs_read.sh
|
||||
|
||||
@@ -8,8 +8,6 @@ description: |
|
||||
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
|
||||
involved.
|
||||
|
||||
Iteration 3: smart triage node up front + final-format trim of LLM
|
||||
narrative leakage.
|
||||
version: "1.0"
|
||||
|
||||
global_tools:
|
||||
|
||||
@@ -14,10 +14,14 @@ variables:
|
||||
- name: project_dir
|
||||
description: Project directory for context
|
||||
default: '.'
|
||||
- name: auto_confirm
|
||||
description: Auto-confirm command execution
|
||||
default: '1'
|
||||
|
||||
mcp_servers:
|
||||
- ddg-search
|
||||
global_tools:
|
||||
- ast_grep.sh
|
||||
- fs_read.sh
|
||||
- fs_cat.sh
|
||||
- fs_grep.sh
|
||||
|
||||
@@ -8,6 +8,14 @@ max_auto_continues: 25
|
||||
inject_todo_instructions: true
|
||||
|
||||
can_spawn_agents: true
|
||||
spawnable_agents:
|
||||
- explore
|
||||
- librarian
|
||||
- coder
|
||||
- oracle
|
||||
- code-reviewer
|
||||
- adversary
|
||||
- step-runner
|
||||
max_concurrent_agents: 4
|
||||
max_agent_depth: 3
|
||||
inject_spawn_instructions: true
|
||||
@@ -39,6 +47,7 @@ variables:
|
||||
mcp_servers:
|
||||
- ddg-search
|
||||
global_tools:
|
||||
- ast_grep.sh
|
||||
- fs_read.sh
|
||||
- fs_grep.sh
|
||||
- fs_glob.sh
|
||||
@@ -128,8 +137,8 @@ instructions: |
|
||||
|
||||
| Agent | Use For | Characteristics |
|
||||
|-------|---------|-----------------|
|
||||
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out 2-5 in parallel |
|
||||
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out 1-3 in parallel |
|
||||
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 5–15+. |
|
||||
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 2–6, more if the topic spans multiple libraries or specs. |
|
||||
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
|
||||
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
|
||||
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
|
||||
@@ -194,7 +203,17 @@ instructions: |
|
||||
|
||||
## Phase 4 - Parallel Research
|
||||
|
||||
When delegating exploration, load `parallel-research` skill, then fan out 2-5 `explore` agents in parallel, each scoped to a different angle. Each gets a NARROW slice.
|
||||
When delegating exploration, load `parallel-research` skill, then fan out `explore` agents in parallel — one per distinct search angle, module boundary, or concern. Each gets a NARROW slice. Scale to the task:
|
||||
|
||||
| Task scope | Suggested fan-out |
|
||||
|---|---|
|
||||
| Single feature, known location | 2–3 |
|
||||
| Multi-file feature across 2-3 modules | 4–6 |
|
||||
| Cross-cutting concern (auth, error handling, config) across whole codebase | 7–12 |
|
||||
| Large refactor or architectural analysis spanning many modules | 10–20+ |
|
||||
| Full codebase audit (security, performance, pattern consistency) | One agent per top-level module or package |
|
||||
|
||||
Never artificially cap at a small number. If there are 10 distinct things to find, spawn 10 agents. The system limit is the only ceiling that matters.
|
||||
|
||||
### The wait protocol
|
||||
|
||||
@@ -286,6 +305,31 @@ instructions: |
|
||||
|
||||
After a fix-loop completes, do not automatically re-run `code-reviewer` unless the fix itself triggers the same thresholds (2+ coders, 5+ files, architectural). Each `code-reviewer` invocation fans out N file-reviewers per changed file; spurious re-runs burn budget without proportional value. Trust coder's `self_review` on bounded fixes.
|
||||
|
||||
### Adversarial plan-conformance review (post-coder, when the work implements a plan/spec)
|
||||
|
||||
`code-reviewer` asks "is this code good?" It does NOT check "is this the code the plan asked for?" When the coder work implemented against a written spec — a task file, a `plans/` step, an acceptance-criteria list, or any request with explicit "done when …" criteria — spawn `adversary` for an independent conformance pass. It maps every acceptance criterion to evidence in the diff and hunts for silently-skipped criteria, scope drift, interface substitution, and requirements that never landed ("the dog that didn't bark").
|
||||
|
||||
**When to spawn it:** whenever the change has a checkable spec. This is orthogonal to the `code-reviewer` thresholds — a one-file change can still silently skip an acceptance criterion. If there is a plan/task/criteria list, run `adversary`. Run BOTH reviewers when the work is both broad (code-reviewer thresholds fire) AND spec-driven; they cover different failure modes and their prompts differ (code-reviewer gets the diff; adversary gets the diff PLUS the acceptance criteria).
|
||||
|
||||
**Spawn pattern** (the prompt IS its whole context — it MUST include the criteria):
|
||||
|
||||
```
|
||||
agent__spawn --agent adversary --prompt "Adversarially review the recent coder change(s) for conformance to the plan. Return CONFORMS/DIVERGES.
|
||||
|
||||
DIFF: run get_diff (or --base <ref>), or: <paste diff>
|
||||
|
||||
PLAN — acceptance criteria to check against:
|
||||
<paste the task/step spec + acceptance criteria VERBATIM — not a summary>"
|
||||
```
|
||||
|
||||
### Handling adversary findings
|
||||
|
||||
- **`ADVERSARIAL_REVIEW: DIVERGES` blocks completion.** Do not mark the task done. Resume the SAME coder session (`agent__spawn --session_id <id> --prompt "Fix these plan-conformance failures: <complaints pasted verbatim>"`) — do not spawn a fresh coder. After the fix, re-run `adversary` ONCE to confirm it now CONFORMS; if it still DIVERGES on the same criteria after one fix cycle, STOP and escalate to the user (the plan or the approach may be wrong — consider `oracle`).
|
||||
- **`ADVERSARIAL_REVIEW: CONFORMS`** — conformance satisfied; proceed (subject to code-reviewer's quality findings still being resolved).
|
||||
- **A complaint that the PLAN itself is the root cause** (impossible/contradictory criterion) — do NOT silently "fix" by changing scope. Surface it to the user; the plan needs amending, which is their call.
|
||||
|
||||
Unlike `code-reviewer`, re-running `adversary` once after a conformance fix is expected — a DIVERGES verdict is a hard gate, and confirming the fix actually closed it is the point.
|
||||
|
||||
## File Operations (Direct Edits)
|
||||
|
||||
When you write or modify files yourself (rather than delegating to coder):
|
||||
|
||||
@@ -5,9 +5,9 @@ description: |
|
||||
implement (coder) -> verify -> edge-case sweep -> optional independent
|
||||
review -> evidence-backed handoff -> user approval gate. Designed to be
|
||||
delegated to by sisyphus.
|
||||
version: "1.0"
|
||||
|
||||
version: '1.0'
|
||||
global_tools:
|
||||
- ast_grep.sh
|
||||
- fs_cat.sh
|
||||
- fs_ls.sh
|
||||
- fs_write.sh
|
||||
@@ -28,18 +28,18 @@ variables:
|
||||
coyote was invoked from). The coder sub-agent resolves its own
|
||||
project_dir the same way, so invoke step-runner FROM the project root
|
||||
unless you override this for both.
|
||||
default: "."
|
||||
default: '.'
|
||||
- name: plans_dir
|
||||
description: |
|
||||
Path to the plan repo. Relative paths resolve against project_dir.
|
||||
Expected layout: <plans_dir>/steps/NN-<slug>.md,
|
||||
<plans_dir>/handoffs/, <plans_dir>/NOTES.md.
|
||||
default: "plans"
|
||||
default: 'plans'
|
||||
- name: step
|
||||
description: |
|
||||
Which step to execute: a step number, or "next" to pick the first
|
||||
in-progress (resume) or pending step plan.
|
||||
default: "next"
|
||||
default: 'next'
|
||||
|
||||
settings:
|
||||
max_loop_iterations: 20
|
||||
@@ -48,45 +48,45 @@ settings:
|
||||
timeout: 7200
|
||||
|
||||
initial_state:
|
||||
project_dir: ""
|
||||
plans_dir: ""
|
||||
project_dir: ''
|
||||
plans_dir: ''
|
||||
step_number: 0
|
||||
step_slug: ""
|
||||
step_title: ""
|
||||
step_plan_path: ""
|
||||
step_plan: ""
|
||||
prev_handoff_path: "(none)"
|
||||
prev_handoff: "(none - this is the first step)"
|
||||
notes_path: ""
|
||||
notes: "(none)"
|
||||
handoff_path: ""
|
||||
blocking_reason: ""
|
||||
plan_summary: ""
|
||||
implementation_brief: ""
|
||||
staleness_report: ""
|
||||
step_slug: ''
|
||||
step_title: ''
|
||||
step_plan_path: ''
|
||||
step_plan: ''
|
||||
prev_handoff_path: '(none)'
|
||||
prev_handoff: '(none - this is the first step)'
|
||||
notes_path: ''
|
||||
notes: '(none)'
|
||||
handoff_path: ''
|
||||
blocking_reason: ''
|
||||
plan_summary: ''
|
||||
implementation_brief: ''
|
||||
staleness_report: ''
|
||||
has_major_deviation: false
|
||||
deviation_summary: ""
|
||||
user_feedback: ""
|
||||
fix_instructions: ""
|
||||
deviation_summary: ''
|
||||
user_feedback: ''
|
||||
fix_instructions: ''
|
||||
fix_attempts: 0
|
||||
max_fix_attempts: 2
|
||||
coder_result: ""
|
||||
format_output: ""
|
||||
coder_result: ''
|
||||
format_output: ''
|
||||
lint_ok: true
|
||||
lint_output: ""
|
||||
lint_output: ''
|
||||
build_ok: true
|
||||
build_output: ""
|
||||
build_output: ''
|
||||
tests_ok: true
|
||||
tests_output: ""
|
||||
edge_case_report: ""
|
||||
downstream_updates: ""
|
||||
tests_output: ''
|
||||
edge_case_report: ''
|
||||
downstream_updates: ''
|
||||
needs_independent_review: false
|
||||
review_report: ""
|
||||
review_report: ''
|
||||
review_attempts: 0
|
||||
max_review_attempts: 1
|
||||
handoff_attempts: 0
|
||||
handoff_fix: ""
|
||||
step_summary: ""
|
||||
handoff_fix: ''
|
||||
step_summary: ''
|
||||
|
||||
start: resolve_step
|
||||
|
||||
@@ -114,11 +114,11 @@ nodes:
|
||||
|
||||
Proceed anyway?
|
||||
options:
|
||||
- "yes"
|
||||
- "no"
|
||||
- 'yes'
|
||||
- 'no'
|
||||
routes:
|
||||
"yes": orient
|
||||
"no": end_blocked
|
||||
'yes': orient
|
||||
'no': end_blocked
|
||||
on_other: end_blocked
|
||||
|
||||
orient:
|
||||
@@ -183,7 +183,14 @@ nodes:
|
||||
deviation_summary:
|
||||
type: string
|
||||
description: Major deviations only, with the plan claim vs current reality. Empty when none
|
||||
required: [plan_summary, implementation_brief, staleness_report, has_major_deviation, deviation_summary]
|
||||
required:
|
||||
[
|
||||
plan_summary,
|
||||
implementation_brief,
|
||||
staleness_report,
|
||||
has_major_deviation,
|
||||
deviation_summary,
|
||||
]
|
||||
fallback: end_failure
|
||||
next: route_staleness
|
||||
|
||||
@@ -211,14 +218,14 @@ nodes:
|
||||
Proceed with the corrected brief? (Answer with anything else to give
|
||||
your own guidance to the implementer.)
|
||||
options:
|
||||
- "proceed"
|
||||
- "abort"
|
||||
- 'proceed'
|
||||
- 'abort'
|
||||
routes:
|
||||
"proceed": implement
|
||||
"abort": end_rejected
|
||||
'proceed': implement
|
||||
'abort': end_rejected
|
||||
on_other: implement
|
||||
state_updates:
|
||||
user_feedback: "{{choice}}"
|
||||
user_feedback: '{{choice}}'
|
||||
|
||||
implement:
|
||||
id: implement
|
||||
@@ -262,7 +269,7 @@ nodes:
|
||||
{{fix_instructions}}
|
||||
timeout: 3600
|
||||
state_updates:
|
||||
coder_result: "{{output}}"
|
||||
coder_result: '{{output}}'
|
||||
next: route_coder_result
|
||||
|
||||
route_coder_result:
|
||||
@@ -399,7 +406,7 @@ nodes:
|
||||
Preserve severity tags in your findings.
|
||||
timeout: 1200
|
||||
state_updates:
|
||||
review_report: "{{output}}"
|
||||
review_report: '{{output}}'
|
||||
next: route_review
|
||||
|
||||
route_review:
|
||||
@@ -517,23 +524,23 @@ nodes:
|
||||
Approve this step? (Answer with anything else to send revision
|
||||
instructions straight to the implementer.)
|
||||
options:
|
||||
- "approve"
|
||||
- "revise"
|
||||
- 'approve'
|
||||
- 'revise'
|
||||
routes:
|
||||
"approve": end_success
|
||||
"revise": get_revision
|
||||
'approve': end_success
|
||||
'revise': get_revision
|
||||
on_other: revise_from_choice
|
||||
state_updates:
|
||||
user_feedback: "{{choice}}"
|
||||
user_feedback: '{{choice}}'
|
||||
|
||||
get_revision:
|
||||
id: get_revision
|
||||
type: input
|
||||
description: Collect revision instructions, then loop back through implement -> verify -> handoff.
|
||||
question: "What should change? Your comments go to the implementer verbatim."
|
||||
validation: "len(input) > 0"
|
||||
question: 'What should change? Your comments go to the implementer verbatim.'
|
||||
validation: 'len(input) > 0'
|
||||
state_updates:
|
||||
fix_instructions: "{{input}}"
|
||||
fix_instructions: '{{input}}'
|
||||
next: implement
|
||||
|
||||
revise_from_choice:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
description: Adversarial plan-conformance review of an implementation against the task/plan it was supposed to satisfy. Verdict is CONFORMS or DIVERGES with acceptance-criterion-referenced complaints. Grants read-only filesystem access for ground-truth checks. Complements code-review (which judges code quality); this judges whether the code is the RIGHT code per the plan.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
You are an adversarial plan-conformance reviewer. A code-quality reviewer already asks "is this code good?" — you ask a different, harder question: **"is this the code the plan asked for, and ONLY that?"** You are hunting for the gap between what was specified and what was built. Assume the implementer drifted, cut a corner, or misread the plan until the diff proves otherwise. Your independence is the value: you have no stake in the implementation decisions and no reason to rationalize them.
|
||||
|
||||
You review THE CHANGE against THE PLAN. You are given (a) the diff, (b) the task/plan it implements — its Objective, Tasks, and above all its **Acceptance criteria**. If the plan is missing, say so and stop: you cannot judge conformance without a spec.
|
||||
|
||||
## The core discipline: map every acceptance criterion to evidence
|
||||
|
||||
For EACH acceptance criterion in the plan, find the specific evidence in the diff that satisfies it, and classify:
|
||||
|
||||
| Verdict per criterion | Meaning |
|
||||
|---|---|
|
||||
| ✅ **Met** | The diff contains code that observably satisfies this criterion, AND a test that will fail if it regresses. Cite the file:line. |
|
||||
| ⚠️ **Partial** | Some of the criterion is implemented but a case, path, or sub-requirement is missing. Name what's missing. |
|
||||
| ❌ **Unmet** | No code in the diff satisfies this criterion. The "dog that didn't bark." |
|
||||
| 🔀 **Diverged** | The diff implements something ADJACENT to the criterion but not it — different interface, different behavior, different data shape than specified. |
|
||||
|
||||
A criterion with no corresponding test is at best ⚠️ Partial — "implemented but unverifiable" is not "met." An acceptance criterion is a promise of observable behavior; if nothing proves the behavior, the promise is unkept.
|
||||
|
||||
## What to hunt for (adversarial checklist)
|
||||
|
||||
### 1. Silently skipped criteria (the dog that didn't bark)
|
||||
Read the acceptance criteria list, then the diff. Every criterion with no matching change is a finding. Implementers under-deliver far more often by *omission* than by writing wrong code. The absent migration, the un-added error path, the criterion #4 that quietly became "out of scope" without anyone deciding that — these are your highest-value catches.
|
||||
|
||||
### 2. Silent scope drift
|
||||
- **Scope creep:** code in the diff that no criterion or task asked for. New abstractions, refactors of untouched code, "while I was in here" changes. Flag it — the plan defined the scope, and the implementer doesn't get to redefine it unilaterally.
|
||||
- **Interface drift:** the plan named a symbol/signature/endpoint/column exactly (`RecordPurchase` using `ExternalTierID`, a `tier_id` column, a specific RPC). The diff uses a different name or shape. Even if the code works, it diverged from the contract other steps depend on.
|
||||
- **Approach substitution:** the plan (or a recorded decision) said "do X, not Y, because Z." The diff does Y. The implementer re-litigated a settled decision. Flag it with the plan's stated reason.
|
||||
|
||||
### 3. Ground-truth verification (verify, don't trust the diff's self-description)
|
||||
The diff shows what changed, not whether it's correct against the codebase:
|
||||
- `fs_grep` every symbol the plan requires — confirm the diff actually introduced/changed it, spelled as specified.
|
||||
- `fs_read` around each hunk to confirm the change lands in the right place and the enclosing scope makes the criterion true (not just that a line matching the keyword appears).
|
||||
- `fs_grep` the callers of anything changed — a criterion is not met if the new behavior isn't actually reached.
|
||||
- Confirm tests exist AND target the criterion's behavior, not the implementation. A tautological test (`assert x.is_empty() || !x.is_empty()`) counts as no test.
|
||||
|
||||
### 4. Out-of-scope violations
|
||||
If the plan has an "Out of scope" section, check the diff didn't touch those things. Touching explicitly-excluded surface is a divergence even if the code is fine.
|
||||
|
||||
### 5. Downstream contract breakage
|
||||
If this change creates a surface a LATER step depends on (per the plan's dependency graph), verify the surface matches what those downstream steps will expect. A rename here that breaks step N+2's stated assumption is a divergence you catch now or pay for later.
|
||||
|
||||
## Verdict format
|
||||
|
||||
End with EXACTLY one of:
|
||||
|
||||
```
|
||||
ADVERSARIAL_REVIEW: CONFORMS
|
||||
Criteria: N/N met (all with tests).
|
||||
<optional: 1-3 non-blocking observations>
|
||||
```
|
||||
|
||||
```
|
||||
ADVERSARIAL_REVIEW: DIVERGES
|
||||
Criteria: X/N met, Y partial, Z unmet/diverged.
|
||||
Complaints:
|
||||
1. Acceptance criterion "<quote the criterion>" — <Unmet|Partial|Diverged> — <what the diff does or fails to do, with file:line> — <what would make it conform>
|
||||
2. Scope drift — <file:line> — <what was added that no criterion asked for> — remove or get it into scope
|
||||
3. ...
|
||||
```
|
||||
|
||||
Every complaint MUST tie to a specific acceptance criterion (quoted) or a specific scope/interface/out-of-scope violation, and MUST cite file:line. "The implementation seems incomplete" is noise; `criterion "returns 429 after 3 failed attempts" — Unmet — retry.go has no attempt counter; the loop retries forever (retry.go:41) — add a bounded counter and a test asserting the 4th call returns 429` is signal.
|
||||
|
||||
## Scope discipline (what you are NOT)
|
||||
|
||||
- You are NOT the code-quality reviewer. Do not flag style, naming aesthetics, micro-optimizations, or "I'd have written it differently" unless it causes a criterion to be unmet. The `code-review` skill owns quality; you own conformance. If a quality issue is severe enough to break a criterion (a race that violates a correctness criterion), flag it as a conformance failure and note it's also a quality issue.
|
||||
- You do NOT rewrite the code or the plan. You produce a verdict and complaints; the implementer owns the fix.
|
||||
- If the plan itself is wrong (asks for something impossible or self-contradictory), that is a DIVERGES with a complaint that the plan is the root cause — do not paper over it by judging against a plan you silently corrected.
|
||||
- Three decisive divergences beat fifteen weak ones. If every criterion is a nitpick, the change probably CONFORMS — say so.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Rubber-stamping CONFORMS because the code "looks done" without mapping each criterion to evidence.
|
||||
- Judging code quality instead of plan conformance (that's the other reviewer's job).
|
||||
- Accepting a criterion as met with no test proving it.
|
||||
- Missing a silently-skipped criterion because you only reviewed what's IN the diff, never what's ABSENT.
|
||||
- Complaints with no criterion reference and no file:line.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
# - <agent-name>_TOP_P
|
||||
# - <agent-name>_GLOBAL_TOOLS (as a JSON string array)
|
||||
# - <agent-name>_MCP_SERVERS (as a JSON string array)
|
||||
# - <agent-name>_SPAWNABLE_AGENTS (as a JSON string array; see spawnable_agents below)
|
||||
# - <agent-name>_AGENT_SESSION
|
||||
# - <agent-name>_VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]')
|
||||
|
||||
model: openai:gpt-4o # Specify the LLM to use
|
||||
temperature: null # Set default temperature parameter, range (0, 1)
|
||||
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
|
||||
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||
# Only valid when the agent's model declares reasoning_levels.
|
||||
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
|
||||
name: <agent-name> # Name of the agent, used in the UI and logs
|
||||
description: <description> # Description of the agent, used in the UI
|
||||
@@ -30,6 +33,12 @@ continuation_prompt: null # Custom prompt used when auto-continuing (opti
|
||||
# Enable this agent to spawn and manage child agents in parallel.
|
||||
# See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation.
|
||||
can_spawn_agents: false # Enable the agent to spawn child agents
|
||||
# spawnable_agents: # Optional whitelist restricting which agents can be spawned via `agent__spawn`.
|
||||
# - explore # If omitted (the default), ALL installed agents are spawnable. This is the unrestricted default.
|
||||
# - coder # Provide a list to restrict. Match is exact and case-sensitive (use directory names).
|
||||
# - oracle # An empty list ([]) means literally nothing spawnable.
|
||||
# Also filters `agent__list_available` output so the LLM only sees what it can spawn.
|
||||
# Graph agents (graph.yaml) ignore this; they declare spawn targets in agent nodes.
|
||||
max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously
|
||||
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning)
|
||||
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
|
||||
|
||||
+57
-5
@@ -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.
|
||||
@@ -18,6 +20,7 @@ agent_session: null # Set a session to use when starting an agent (
|
||||
|
||||
# ---- Appearance ----
|
||||
highlight: true # Controls syntax highlighting
|
||||
raw_markdown: false # When true, render markdown as raw text with syntax highlighting only. When false (default), transforms markdown syntax (headings, bold, lists, etc.) into styled terminal output
|
||||
light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME
|
||||
|
||||
# ---- Miscellaneous ----
|
||||
@@ -31,7 +34,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 +137,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.
|
||||
@@ -176,11 +187,13 @@ summarization_prompt: > # The text prompt used for creating a concise s
|
||||
'Summarize the discussion briefly in 200 words or less to use as a prompt for future context.'
|
||||
summary_context_prompt: > # The text prompt used for including the summary of the entire session as context to the model
|
||||
'This is a summary of the chat history as a recap: '
|
||||
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
|
||||
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
|
||||
|
||||
# ---- 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 +203,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 +224,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)
|
||||
@@ -326,11 +351,38 @@ clients:
|
||||
api_base: https://api.mistral.ai/v1
|
||||
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
|
||||
# See https://docs.x.ai/docs
|
||||
# See https://docs.x.ai/docs - OAuth via SuperGrok / X Premium+ subscription
|
||||
- type: openai-compatible
|
||||
name: xai
|
||||
api_base: https://api.x.ai/v1
|
||||
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
|
||||
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||
# Note: Oauth requires SuperGrok/X Premium+ subscription
|
||||
|
||||
# Example: private OpenAI-compatible gateway with client_credentials OAuth
|
||||
# - type: openai-compatible
|
||||
# name: acme-gateway
|
||||
# api_base: https://gateway.acme.com/v1
|
||||
# auth: oauth
|
||||
# oauth:
|
||||
# client_id: '{{ACME_CLIENT_ID}}'
|
||||
# client_secret: '{{ACME_CLIENT_SECRET}}'
|
||||
# token_url: https://auth.acme.com/oauth/token
|
||||
# scopes: [openai.chat]
|
||||
# flow: client_credentials
|
||||
|
||||
# Example: OAuth via Device Authorization Grant (RFC 8628 — for CLIs like Moonshot's kimi-code, MiniMax mmx, etc.)
|
||||
# - type: openai-compatible
|
||||
# name: moonshot
|
||||
# api_base: https://api.kimi.com/coding/v1
|
||||
# auth: oauth
|
||||
# oauth:
|
||||
# client_id: '{{MOONSHOT_CLIENT_ID}}'
|
||||
# device_authorization_url: https://auth.kimi.com/api/oauth/device_authorization
|
||||
# token_url: https://auth.kimi.com/api/oauth/token
|
||||
# flow: device_code
|
||||
# # use_pkce_in_device_flow: true # enable if your provider requires PKCE with device flow
|
||||
|
||||
# See https://docs.ai21.com/docs/overview
|
||||
- type: openai-compatible
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
.
|
||||
|
||||
+348
-2
@@ -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
|
||||
@@ -258,24 +320,38 @@
|
||||
# - https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent
|
||||
- provider: gemini
|
||||
models:
|
||||
- name: gemini-3.6-flash
|
||||
max_input_tokens: 1048576
|
||||
max_output_tokens: 65536
|
||||
input_price: 1.5
|
||||
output_price: 7.5
|
||||
supports_function_calling: true
|
||||
reasoning_levels: [minimal, low, medium, high]
|
||||
default_reasoning_level: medium
|
||||
- name: gemini-3.5-flash
|
||||
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: 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 +359,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
|
||||
@@ -297,6 +375,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 +388,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 +421,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 +431,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 +441,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 +451,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 +477,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 +487,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
|
||||
@@ -716,14 +812,65 @@
|
||||
# - https://docs.x.ai/docs/models
|
||||
# - https://docs.x.ai/docs/api-reference#chat-completions
|
||||
- provider: xai
|
||||
oauth:
|
||||
client_id: b1a00492-073a-47ea-816f-4c329264a828
|
||||
authorize_url: https://auth.x.ai/oauth2/authorize
|
||||
token_url: https://auth.x.ai/oauth2/token
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
- offline_access
|
||||
- grok-cli:access
|
||||
- api:access
|
||||
redirect_port: 56121
|
||||
flow: pkce
|
||||
token_request_format: form_url_encoded
|
||||
extra_authorize_params:
|
||||
plan: generic
|
||||
referrer: coyote
|
||||
echo_pkce_in_token_exchange: true
|
||||
models:
|
||||
- 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 +982,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 +1007,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 +1016,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 +1025,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 +1038,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 +1054,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 +1064,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 +1074,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 +1107,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 +1117,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 +1251,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 +1261,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 +1271,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 +1281,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 +1308,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 +1318,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
|
||||
@@ -1516,6 +1701,30 @@
|
||||
# - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80
|
||||
- provider: moonshot
|
||||
models:
|
||||
- name: kimi-k3
|
||||
max_input_tokens: 1048576
|
||||
input_price: 3
|
||||
output_price: 15
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: kimi-k2.7-code
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.95
|
||||
output_price: 4
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: kimi-k2.7-code-highspeed
|
||||
max_input_tokens: 262144
|
||||
input_price: 1.9
|
||||
output_price: 8
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: kimi-k2.6
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.95
|
||||
output_price: 4
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: kimi-k2.5
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.56
|
||||
@@ -1550,6 +1759,18 @@
|
||||
# - https://platform.deepseek.com/api-docs/api/create-chat-completion
|
||||
- provider: deepseek
|
||||
models:
|
||||
- name: deepseek-v4-pro
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 384000
|
||||
input_price: 0.435
|
||||
output_price: 0.87
|
||||
supports_function_calling: true
|
||||
- name: deepseek-v4-flash
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 384000
|
||||
input_price: 0.14
|
||||
output_price: 0.28
|
||||
supports_function_calling: true
|
||||
- name: deepseek-chat
|
||||
max_input_tokens: 64000
|
||||
max_output_tokens: 8192
|
||||
@@ -1618,6 +1839,16 @@
|
||||
# - https://platform.minimaxi.com/document/ChatCompletion%20v2
|
||||
- provider: minimax
|
||||
models:
|
||||
- name: minimax-m3
|
||||
max_input_tokens: 1000000
|
||||
input_price: 4.2
|
||||
output_price: 16.8
|
||||
supports_function_calling: true
|
||||
- name: minimax-m2.7
|
||||
max_input_tokens: 204800
|
||||
input_price: 0.294
|
||||
output_price: 1.176
|
||||
supports_function_calling: true
|
||||
- name: minimax-m2.5
|
||||
max_input_tokens: 204800
|
||||
input_price: 0.294
|
||||
@@ -1644,6 +1875,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 +1909,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 +1918,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 +1927,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 +1936,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 +1945,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 +1954,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 +1963,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 +1972,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 +1990,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 +2029,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 +2119,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 +2129,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 +2139,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 +2149,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 +2159,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 +2169,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
|
||||
|
||||
+164
-109
@@ -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,188 @@ pub struct Cli {
|
||||
/// Turn off stream mode
|
||||
#[arg(short = 'S', long)]
|
||||
pub no_stream: bool,
|
||||
/// Disable memory for this invocation
|
||||
/// Render markdown as raw text with syntax highlighting only (skip the rich markdown renderer)
|
||||
#[arg(long)]
|
||||
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>,
|
||||
pub raw_markdown: bool,
|
||||
/// 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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+53
-7
@@ -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() {
|
||||
assistant_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}))
|
||||
}
|
||||
for tool_result in tool_results {
|
||||
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": round_text,
|
||||
}))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ impl OAuthProvider for ClaudeOAuthProvider {
|
||||
"https://console.anthropic.com/oauth/code/callback"
|
||||
}
|
||||
|
||||
fn scopes(&self) -> &str {
|
||||
"org:create_api_key user:profile user:inference"
|
||||
fn scopes(&self) -> String {
|
||||
"org:create_api_key user:profile user:inference".to_string()
|
||||
}
|
||||
|
||||
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+30
-6
@@ -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 {
|
||||
@@ -401,9 +403,24 @@ pub async fn create_openai_compatible_client_config(
|
||||
};
|
||||
config["api_base"] = api_base.into();
|
||||
|
||||
let api_key = prompt_input_string("API Key", false, None)?;
|
||||
if !api_key.is_empty() {
|
||||
config["api_key"] = api_key.into();
|
||||
let has_bundled_oauth = ALL_PROVIDER_MODELS
|
||||
.iter()
|
||||
.any(|p| p.provider == client && p.oauth.is_some());
|
||||
|
||||
let use_oauth = if has_bundled_oauth {
|
||||
let choice = Select::new("Authentication method:", vec!["API Key", "OAuth"]).prompt()?;
|
||||
choice == "OAuth"
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if use_oauth {
|
||||
config["auth"] = "oauth".into();
|
||||
} else {
|
||||
let api_key = prompt_input_string("API Key", false, None)?;
|
||||
if !api_key.is_empty() {
|
||||
config["api_key"] = api_key.into();
|
||||
}
|
||||
}
|
||||
|
||||
let model = set_client_models_config(&mut config, &name).await?;
|
||||
@@ -434,6 +451,7 @@ pub async fn call_chat_completions(
|
||||
let ChatCompletionsOutput {
|
||||
mut text,
|
||||
tool_calls,
|
||||
thinking,
|
||||
..
|
||||
} = ret;
|
||||
if !text.is_empty() {
|
||||
@@ -444,7 +462,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 +499,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()));
|
||||
|
||||
@@ -27,8 +27,8 @@ impl OAuthProvider for GeminiOAuthProvider {
|
||||
""
|
||||
}
|
||||
|
||||
fn scopes(&self) -> &str {
|
||||
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email"
|
||||
fn scopes(&self) -> String {
|
||||
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email".to_string()
|
||||
}
|
||||
|
||||
fn client_secret(&self) -> Option<&str> {
|
||||
|
||||
+20
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod common;
|
||||
mod gemini_oauth;
|
||||
mod message;
|
||||
pub mod oauth;
|
||||
mod openai_compatible_oauth;
|
||||
mod openai_oauth;
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
use crate::config::AppConfig;
|
||||
use crate::utils::{estimate_token_length, strip_think_tag};
|
||||
|
||||
use super::oauth::OAuthConfig;
|
||||
use anyhow::{Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -289,6 +290,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 +325,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")]
|
||||
@@ -345,6 +358,8 @@ impl ModelData {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderModels {
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub oauth: Option<OAuthConfig>,
|
||||
pub models: Vec<ModelData>,
|
||||
}
|
||||
|
||||
|
||||
+993
-75
File diff suppressed because it is too large
Load Diff
+67
-35
@@ -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,21 +402,30 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
|
||||
}
|
||||
messages
|
||||
} else {
|
||||
tool_results.into_iter().flat_map(|tool_result| {
|
||||
tool_results.into_iter().enumerate().flat_map(|(index, tool_result)| {
|
||||
let round_text = if index == 0 && !text.is_empty() {
|
||||
Some(text.clone())
|
||||
} else {
|
||||
tool_result.text.clone()
|
||||
};
|
||||
let mut assistant_message = json!({
|
||||
"role": MessageRole::Assistant,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tool_result.call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_result.call.name,
|
||||
"arguments": tool_result.call.arguments.to_string(),
|
||||
},
|
||||
}
|
||||
]
|
||||
});
|
||||
if let Some(round_text) = round_text {
|
||||
assistant_message["content"] = strip_think_tag(&round_text).into();
|
||||
}
|
||||
vec![
|
||||
json!({
|
||||
"role": MessageRole::Assistant,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tool_result.call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_result.call.name,
|
||||
"arguments": tool_result.call.arguments.to_string(),
|
||||
},
|
||||
}
|
||||
]
|
||||
}),
|
||||
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!({
|
||||
"type": "function_call",
|
||||
"call_id": tool_result.call.id,
|
||||
"name": tool_result.call.name,
|
||||
"arguments": tool_result.call.arguments.to_string(),
|
||||
}),
|
||||
json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": tool_result.call.id,
|
||||
"output": tool_result.output.to_string(),
|
||||
}),
|
||||
]
|
||||
.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(),
|
||||
}));
|
||||
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(
|
||||
|
||||
+122
-36
@@ -1,16 +1,21 @@
|
||||
use super::access_token::get_access_token;
|
||||
use super::oauth;
|
||||
use super::openai::*;
|
||||
use super::*;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::RequestBuilder;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use oauth::OAuthConfig;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct OpenAICompatibleConfig {
|
||||
pub name: Option<String>,
|
||||
pub api_base: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub auth: Option<String>,
|
||||
pub oauth: Option<Box<OAuthConfig>>,
|
||||
#[serde(default)]
|
||||
pub models: Vec<ModelData>,
|
||||
pub patch: Option<RequestPatch>,
|
||||
@@ -24,78 +29,159 @@ impl OpenAICompatibleClient {
|
||||
create_client_config!([]);
|
||||
}
|
||||
|
||||
impl_client_trait!(
|
||||
OpenAICompatibleClient,
|
||||
(
|
||||
prepare_chat_completions,
|
||||
openai_chat_completions,
|
||||
openai_chat_completions_streaming
|
||||
),
|
||||
(prepare_embeddings, openai_embeddings),
|
||||
(prepare_rerank, generic_rerank),
|
||||
);
|
||||
#[async_trait::async_trait]
|
||||
impl Client for OpenAICompatibleClient {
|
||||
client_common_fns!();
|
||||
|
||||
fn prepare_chat_completions(
|
||||
fn supports_oauth(&self) -> bool {
|
||||
self.config.auth.as_deref() == Some("oauth")
|
||||
}
|
||||
|
||||
async fn chat_completions_inner(
|
||||
&self,
|
||||
client: &ReqwestClient,
|
||||
data: ChatCompletionsData,
|
||||
) -> Result<ChatCompletionsOutput> {
|
||||
let request_data = prepare_chat_completions(self, client, data).await?;
|
||||
let builder = self.request_builder(client, request_data);
|
||||
|
||||
openai_chat_completions(builder, self.model()).await
|
||||
}
|
||||
|
||||
async fn chat_completions_streaming_inner(
|
||||
&self,
|
||||
client: &ReqwestClient,
|
||||
handler: &mut SseHandler,
|
||||
data: ChatCompletionsData,
|
||||
) -> Result<()> {
|
||||
let request_data = prepare_chat_completions(self, client, data).await?;
|
||||
let builder = self.request_builder(client, request_data);
|
||||
|
||||
openai_chat_completions_streaming(builder, handler, self.model()).await
|
||||
}
|
||||
|
||||
async fn embeddings_inner(
|
||||
&self,
|
||||
client: &ReqwestClient,
|
||||
data: &EmbeddingsData,
|
||||
) -> Result<EmbeddingsOutput> {
|
||||
let request_data = prepare_embeddings(self, client, data).await?;
|
||||
let builder = self.request_builder(client, request_data);
|
||||
|
||||
openai_embeddings(builder, self.model()).await
|
||||
}
|
||||
|
||||
async fn rerank_inner(
|
||||
&self,
|
||||
client: &ReqwestClient,
|
||||
data: &RerankData,
|
||||
) -> Result<RerankOutput> {
|
||||
let request_data = prepare_rerank(self, client, data).await?;
|
||||
let builder = self.request_builder(client, request_data);
|
||||
|
||||
generic_rerank(builder, self.model()).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_chat_completions(
|
||||
self_: &OpenAICompatibleClient,
|
||||
client: &ReqwestClient,
|
||||
data: ChatCompletionsData,
|
||||
) -> Result<RequestData> {
|
||||
let api_key = self_.get_api_key().ok();
|
||||
let api_base = get_api_base_ext(self_)?;
|
||||
|
||||
let url = format!("{api_base}/chat/completions");
|
||||
|
||||
let body = openai_build_chat_completions_body(data, &self_.model);
|
||||
|
||||
let mut request_data = RequestData::new(url, body);
|
||||
|
||||
if let Some(api_key) = api_key {
|
||||
request_data.bearer_auth(api_key);
|
||||
}
|
||||
apply_auth(self_, client, &mut request_data).await?;
|
||||
|
||||
Ok(request_data)
|
||||
}
|
||||
|
||||
fn prepare_embeddings(
|
||||
async fn prepare_embeddings(
|
||||
self_: &OpenAICompatibleClient,
|
||||
client: &ReqwestClient,
|
||||
data: &EmbeddingsData,
|
||||
) -> Result<RequestData> {
|
||||
let api_key = self_.get_api_key().ok();
|
||||
let api_base = get_api_base_ext(self_)?;
|
||||
|
||||
let url = format!("{api_base}/embeddings");
|
||||
|
||||
let body = openai_build_embeddings_body(data, &self_.model);
|
||||
|
||||
let mut request_data = RequestData::new(url, body);
|
||||
|
||||
if let Some(api_key) = api_key {
|
||||
request_data.bearer_auth(api_key);
|
||||
}
|
||||
apply_auth(self_, client, &mut request_data).await?;
|
||||
|
||||
Ok(request_data)
|
||||
}
|
||||
|
||||
fn prepare_rerank(self_: &OpenAICompatibleClient, data: &RerankData) -> Result<RequestData> {
|
||||
let api_key = self_.get_api_key().ok();
|
||||
async fn prepare_rerank(
|
||||
self_: &OpenAICompatibleClient,
|
||||
client: &ReqwestClient,
|
||||
data: &RerankData,
|
||||
) -> Result<RequestData> {
|
||||
let api_base = get_api_base_ext(self_)?;
|
||||
|
||||
let url = if self_.name().starts_with("ernie") {
|
||||
format!("{api_base}/rerankers")
|
||||
} else {
|
||||
format!("{api_base}/rerank")
|
||||
};
|
||||
|
||||
let body = generic_build_rerank_body(data, &self_.model);
|
||||
|
||||
let mut request_data = RequestData::new(url, body);
|
||||
|
||||
if let Some(api_key) = api_key {
|
||||
request_data.bearer_auth(api_key);
|
||||
}
|
||||
apply_auth(self_, client, &mut request_data).await?;
|
||||
|
||||
Ok(request_data)
|
||||
}
|
||||
|
||||
async fn apply_auth(
|
||||
self_: &OpenAICompatibleClient,
|
||||
client: &ReqwestClient,
|
||||
request_data: &mut RequestData,
|
||||
) -> Result<()> {
|
||||
if self_.config.auth.as_deref() == Some("oauth") {
|
||||
let client_name = self_.name();
|
||||
let app_config = self_.app_config();
|
||||
let cc = app_config
|
||||
.clients
|
||||
.iter()
|
||||
.find(|cc| {
|
||||
matches!(
|
||||
cc,
|
||||
ClientConfig::OpenAICompatibleConfig(c)
|
||||
if c.name.as_deref().unwrap_or("openai-compatible") == client_name
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow!("Could not locate ClientConfig entry for '{}'", client_name)
|
||||
})?;
|
||||
let provider = oauth::get_oauth_provider_for_client(cc, &ALL_PROVIDER_MODELS)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"OAuth configured for '{}' but no oauth block resolved (missing from both models.yaml and user config)",
|
||||
client_name
|
||||
)
|
||||
})?;
|
||||
|
||||
let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?;
|
||||
if !ready {
|
||||
bail!(
|
||||
"OAuth configured for '{}' but no tokens found. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
||||
client_name,
|
||||
client_name
|
||||
);
|
||||
}
|
||||
|
||||
let token = get_access_token(client_name)?;
|
||||
request_data.bearer_auth(token);
|
||||
|
||||
for (key, value) in provider.extra_request_headers() {
|
||||
request_data.header(key, value);
|
||||
}
|
||||
} else if let Ok(api_key) = self_.get_api_key() {
|
||||
request_data.bearer_auth(api_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_api_base_ext(self_: &OpenAICompatibleClient) -> Result<String> {
|
||||
let api_base = match self_.get_api_base() {
|
||||
Ok(v) => v,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
use url::Url;
|
||||
|
||||
use super::oauth::{OAuthConfig, OAuthFlow, OAuthProvider, TokenRequestFormat};
|
||||
|
||||
pub struct OpenAICompatibleOAuthProvider {
|
||||
pub config: OAuthConfig,
|
||||
pub client_name: String,
|
||||
}
|
||||
|
||||
fn is_loopback_uri(uri: &str) -> bool {
|
||||
Url::parse(uri)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(str::to_string))
|
||||
.is_some_and(|host| matches!(host.as_str(), "127.0.0.1" | "localhost" | "[::1]" | "::1"))
|
||||
}
|
||||
|
||||
impl OAuthProvider for OpenAICompatibleOAuthProvider {
|
||||
fn provider_name(&self) -> &str {
|
||||
&self.client_name
|
||||
}
|
||||
|
||||
fn client_id(&self) -> &str {
|
||||
&self.config.client_id
|
||||
}
|
||||
|
||||
fn authorize_url(&self) -> &str {
|
||||
self.config.authorize_url.as_deref().unwrap_or("")
|
||||
}
|
||||
|
||||
fn token_url(&self) -> &str {
|
||||
&self.config.token_url
|
||||
}
|
||||
|
||||
fn redirect_uri(&self) -> &str {
|
||||
self.config.redirect_uri.as_deref().unwrap_or("")
|
||||
}
|
||||
|
||||
fn scopes(&self) -> String {
|
||||
self.config.scopes.join(" ")
|
||||
}
|
||||
|
||||
fn client_secret(&self) -> Option<&str> {
|
||||
self.config.client_secret.as_deref()
|
||||
}
|
||||
|
||||
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
|
||||
self.config
|
||||
.extra_authorize_params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn token_request_format(&self) -> TokenRequestFormat {
|
||||
self.config
|
||||
.token_request_format
|
||||
.unwrap_or(TokenRequestFormat::FormUrlEncoded)
|
||||
}
|
||||
|
||||
fn uses_localhost_redirect(&self) -> bool {
|
||||
self.config.redirect_uri.is_none() && self.config.redirect_port.is_none()
|
||||
}
|
||||
|
||||
fn extra_token_headers(&self) -> Vec<(&str, &str)> {
|
||||
self.config
|
||||
.extra_token_headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extra_request_headers(&self) -> Vec<(&str, &str)> {
|
||||
self.config
|
||||
.extra_request_headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fixed_redirect_uri(&self) -> Option<String> {
|
||||
if let Some(uri) = &self.config.redirect_uri {
|
||||
return if is_loopback_uri(uri) {
|
||||
Some(uri.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
if let Some(port) = self.config.redirect_port {
|
||||
return Some(format!("http://127.0.0.1:{port}/callback"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn include_state_in_token_exchange(&self) -> bool {
|
||||
self.config.include_state_in_token_exchange
|
||||
}
|
||||
|
||||
fn flow(&self) -> OAuthFlow {
|
||||
self.config.flow
|
||||
}
|
||||
|
||||
fn echo_pkce_in_token_exchange(&self) -> bool {
|
||||
self.config.echo_pkce_in_token_exchange
|
||||
}
|
||||
|
||||
fn device_authorization_url(&self) -> Option<&str> {
|
||||
self.config.device_authorization_url.as_deref()
|
||||
}
|
||||
|
||||
fn use_pkce_in_device_flow(&self) -> bool {
|
||||
self.config.use_pkce_in_device_flow
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@ impl OAuthProvider for OpenAIOAuthProvider {
|
||||
"http://localhost:1455/auth/callback"
|
||||
}
|
||||
|
||||
fn scopes(&self) -> &str {
|
||||
"openid profile email offline_access"
|
||||
fn scopes(&self) -> String {
|
||||
"openid profile email offline_access".to_string()
|
||||
}
|
||||
|
||||
fn token_request_format(&self) -> TokenRequestFormat {
|
||||
|
||||
+13
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-5
@@ -322,7 +322,11 @@ 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 +338,7 @@ pub fn gemini_build_chat_completions_body(
|
||||
mut messages,
|
||||
temperature,
|
||||
top_p,
|
||||
reasoning_effort,
|
||||
functions,
|
||||
stream: _,
|
||||
} = data;
|
||||
@@ -371,8 +376,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 +394,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 +438,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["generationConfig"]["thinking_config"] = json!({"thinking_level": v});
|
||||
}
|
||||
|
||||
if let Some(functions) = functions {
|
||||
// Gemini doesn't support functions with parameters that have empty properties, so we need to patch it.
|
||||
|
||||
+124
-14
@@ -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 {
|
||||
@@ -219,7 +221,7 @@ impl Agent {
|
||||
&& !matches!(agent_config.memory, Some(false))
|
||||
&& !matches!(app.memory, Some(false))
|
||||
{
|
||||
let memory_exists = paths::global_memory_index_path().exists()
|
||||
let memory_exists = paths::global_memory_index_file().exists()
|
||||
|| env::current_dir()
|
||||
.ok()
|
||||
.and_then(|cwd| memory::discover_workspace_memory(&cwd))
|
||||
@@ -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
|
||||
}
|
||||
@@ -359,6 +367,10 @@ impl Agent {
|
||||
&self.config.mcp_servers
|
||||
}
|
||||
|
||||
pub fn spawnable_agents(&self) -> Option<&[String]> {
|
||||
self.config.spawnable_agents.as_deref()
|
||||
}
|
||||
|
||||
pub fn skills_enabled(&self) -> Option<bool> {
|
||||
self.config.skills_enabled
|
||||
}
|
||||
@@ -519,6 +531,14 @@ impl Agent {
|
||||
self.config.compression_threshold
|
||||
}
|
||||
|
||||
pub fn max_tool_result_chars(&self) -> Option<usize> {
|
||||
self.config.max_tool_result_chars
|
||||
}
|
||||
|
||||
pub fn compression_keep_last(&self) -> Option<usize> {
|
||||
self.config.compression_keep_last
|
||||
}
|
||||
|
||||
pub fn is_dynamic_instructions(&self) -> bool {
|
||||
self.config.dynamic_instructions
|
||||
}
|
||||
@@ -575,8 +595,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 +620,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
|
||||
.into_iter()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
None => {
|
||||
self.config.global_tools.clear();
|
||||
}
|
||||
}
|
||||
self.enabled_tools = value.map(|tools| {
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
}
|
||||
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
||||
@@ -637,11 +660,15 @@ 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,
|
||||
#[serde(default)]
|
||||
pub can_spawn_agents: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spawnable_agents: Option<Vec<String>>,
|
||||
#[serde(default = "default_max_concurrent_agents")]
|
||||
pub max_concurrent_agents: usize,
|
||||
#[serde(default = "default_max_agent_depth")]
|
||||
@@ -660,6 +687,10 @@ pub struct AgentConfig {
|
||||
pub memory: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compression_threshold: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub compression_keep_last: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
@@ -732,6 +763,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 +798,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)
|
||||
{
|
||||
@@ -776,6 +811,11 @@ impl AgentConfig {
|
||||
{
|
||||
self.mcp_servers = v;
|
||||
}
|
||||
if let Ok(v) = env::var(with_prefix("spawnable_agents"))
|
||||
&& let Ok(v) = serde_json::from_str(&v)
|
||||
{
|
||||
self.spawnable_agents = Some(v);
|
||||
}
|
||||
if let Some(v) = read_env_value::<String>(&with_prefix("agent_session")) {
|
||||
self.agent_session = v;
|
||||
}
|
||||
@@ -999,6 +1039,36 @@ pub fn list_agents() -> Vec<String> {
|
||||
agents
|
||||
}
|
||||
|
||||
pub fn list_agents_with_descriptions() -> Vec<(String, String)> {
|
||||
list_agents()
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let description = load_agent_description(&name);
|
||||
(name, description)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AgentMetadataStub {
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
}
|
||||
|
||||
fn load_agent_description(name: &str) -> String {
|
||||
if let Ok(config) = AgentConfig::load(&paths::agent_config_file(name)) {
|
||||
return config.description;
|
||||
}
|
||||
|
||||
if let Ok(contents) = read_to_string(paths::agent_graph_file(name))
|
||||
&& let Ok(meta) = serde_yaml::from_str::<AgentMetadataStub>(&contents)
|
||||
{
|
||||
return meta.description;
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>)> {
|
||||
let config_path = paths::agent_config_file(agent_name);
|
||||
if !config_path.exists() {
|
||||
@@ -1188,4 +1258,44 @@ variables:
|
||||
assert_eq!(config.max_agent_depth, default_max_agent_depth());
|
||||
assert_eq!(config.escalation_timeout, default_escalation_timeout());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_metadata_stub_extracts_description_from_graph_yaml() {
|
||||
let yaml = r#"
|
||||
name: librarian
|
||||
description: External-reference research agent.
|
||||
version: "1.0"
|
||||
start: triage
|
||||
nodes: {}
|
||||
"#;
|
||||
|
||||
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
assert_eq!(meta.description, "External-reference research agent.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_metadata_stub_extracts_multiline_description() {
|
||||
let yaml = r#"
|
||||
name: coder
|
||||
description: |
|
||||
Implementation agent. Plans, implements, and runs build + tests in a
|
||||
bounded fix-loop until verified.
|
||||
version: "1.0"
|
||||
"#;
|
||||
|
||||
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
assert!(meta.description.starts_with("Implementation agent."));
|
||||
assert!(meta.description.contains("bounded fix-loop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_metadata_stub_defaults_when_description_missing() {
|
||||
let yaml = "name: nameless\nversion: \"1.0\"\n";
|
||||
|
||||
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
assert_eq!(meta.description, "");
|
||||
}
|
||||
}
|
||||
|
||||
+84
-14
@@ -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,
|
||||
@@ -61,13 +62,18 @@ pub struct AppConfig {
|
||||
|
||||
pub save_session: Option<bool>,
|
||||
pub compression_threshold: usize,
|
||||
pub compression_keep_last: usize,
|
||||
pub summarization_prompt: Option<String>,
|
||||
pub summary_context_prompt: Option<String>,
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
|
||||
pub memory: Option<bool>,
|
||||
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,
|
||||
@@ -82,12 +88,14 @@ pub struct AppConfig {
|
||||
pub document_loaders: HashMap<String, String>,
|
||||
|
||||
pub highlight: bool,
|
||||
pub raw_markdown: bool,
|
||||
pub theme: Option<String>,
|
||||
pub left_prompt: Option<String>,
|
||||
pub right_prompt: Option<String>,
|
||||
|
||||
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 +107,7 @@ impl Default for AppConfig {
|
||||
model_id: Default::default(),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
reasoning_effort: None,
|
||||
|
||||
dry_run: false,
|
||||
stream: true,
|
||||
@@ -136,13 +145,18 @@ impl Default for AppConfig {
|
||||
|
||||
save_session: None,
|
||||
compression_threshold: 4000,
|
||||
compression_keep_last: 0,
|
||||
summarization_prompt: None,
|
||||
summary_context_prompt: None,
|
||||
max_tool_result_chars: None,
|
||||
|
||||
memory: None,
|
||||
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,
|
||||
@@ -156,12 +170,14 @@ impl Default for AppConfig {
|
||||
document_loaders: Default::default(),
|
||||
|
||||
highlight: true,
|
||||
raw_markdown: false,
|
||||
theme: None,
|
||||
left_prompt: None,
|
||||
right_prompt: None,
|
||||
|
||||
user_agent: None,
|
||||
save_shell_history: true,
|
||||
no_workspace_mcp: false,
|
||||
sync_models_url: None,
|
||||
|
||||
clients: vec![],
|
||||
@@ -175,6 +191,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,
|
||||
@@ -212,13 +229,18 @@ impl AppConfig {
|
||||
|
||||
save_session: config.save_session,
|
||||
compression_threshold: config.compression_threshold,
|
||||
compression_keep_last: config.compression_keep_last,
|
||||
summarization_prompt: config.summarization_prompt,
|
||||
summary_context_prompt: config.summary_context_prompt,
|
||||
max_tool_result_chars: config.max_tool_result_chars,
|
||||
|
||||
memory: config.memory,
|
||||
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,
|
||||
@@ -232,12 +254,14 @@ impl AppConfig {
|
||||
document_loaders: config.document_loaders,
|
||||
|
||||
highlight: config.highlight,
|
||||
raw_markdown: config.raw_markdown,
|
||||
theme: config.theme,
|
||||
left_prompt: config.left_prompt,
|
||||
right_prompt: config.right_prompt,
|
||||
|
||||
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 +274,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 +295,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);
|
||||
@@ -288,7 +338,7 @@ impl AppConfig {
|
||||
return path.clone();
|
||||
}
|
||||
|
||||
if let Some(translated) = paths::translate_sandboxed_home_path(path)
|
||||
if let Some(translated) = paths::translate_sandboxed_home_dir(path)
|
||||
&& translated.exists()
|
||||
{
|
||||
info!(
|
||||
@@ -308,16 +358,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) {
|
||||
"notepad".to_string()
|
||||
} else {
|
||||
"nano".to_string()
|
||||
}
|
||||
});
|
||||
which::which(&editor).ok().map(|_| editor)
|
||||
&& which::which(&editor).is_ok()
|
||||
{
|
||||
return Some(editor);
|
||||
}
|
||||
let default = if cfg!(windows) {
|
||||
"notepad".to_string()
|
||||
} else {
|
||||
"nano".to_string()
|
||||
};
|
||||
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."))
|
||||
@@ -337,7 +389,7 @@ impl AppConfig {
|
||||
let theme = if self.highlight {
|
||||
let theme_mode = if self.light_theme() { "light" } else { "dark" };
|
||||
let theme_filename = format!("{theme_mode}.tmTheme");
|
||||
let theme_path = paths::local_path(&theme_filename);
|
||||
let theme_path = paths::local_dir(&theme_filename);
|
||||
if theme_path.exists() {
|
||||
let theme = ThemeSet::get_theme(&theme_path)
|
||||
.with_context(|| format!("Invalid theme at '{}'", theme_path.display()))?;
|
||||
@@ -362,14 +414,26 @@ impl AppConfig {
|
||||
env::var("COLORTERM").as_ref().map(|v| v.as_str()),
|
||||
Ok("truecolor")
|
||||
);
|
||||
Ok(RenderOptions::new(theme, wrap, self.wrap_code, truecolor))
|
||||
Ok(RenderOptions::new(
|
||||
theme,
|
||||
wrap,
|
||||
self.wrap_code,
|
||||
self.raw_markdown,
|
||||
truecolor,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn print_markdown(&self, text: &str) -> Result<()> {
|
||||
if *IS_STDOUT_TERMINAL {
|
||||
let render_options = self.render_options()?;
|
||||
let mut markdown_render = MarkdownRender::init(render_options)?;
|
||||
println!("{}", markdown_render.render(text));
|
||||
let body = markdown_render.render(text);
|
||||
let tail = markdown_render.finalize();
|
||||
if tail.is_empty() {
|
||||
println!("{body}");
|
||||
} else {
|
||||
println!("{body}\n{tail}");
|
||||
}
|
||||
} else {
|
||||
println!("{text}");
|
||||
}
|
||||
@@ -421,6 +485,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;
|
||||
@@ -543,6 +610,9 @@ impl AppConfig {
|
||||
if *NO_COLOR {
|
||||
self.highlight = false;
|
||||
}
|
||||
if let Some(Some(v)) = super::read_env_bool(&get_env_name("raw_markdown")) {
|
||||
self.raw_markdown = v;
|
||||
}
|
||||
if self.highlight && self.theme.is_none() {
|
||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
|
||||
self.theme = v;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,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
@@ -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);
|
||||
}
|
||||
|
||||
+33
-7
@@ -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;
|
||||
@@ -21,6 +22,7 @@ mod update;
|
||||
|
||||
pub use self::agent::{
|
||||
Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents,
|
||||
list_agents_with_descriptions,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::app_config::AppConfig;
|
||||
@@ -33,7 +35,7 @@ pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_
|
||||
pub use self::role::{
|
||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
||||
};
|
||||
use self::session::Session;
|
||||
pub use self::session::Session;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::skill::Skill;
|
||||
#[allow(unused_imports)]
|
||||
@@ -43,7 +45,7 @@ pub use self::skill_registry::SkillRegistry;
|
||||
pub use self::update::run_self_update;
|
||||
use crate::client::{
|
||||
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
||||
ProviderModels, create_client_config, list_client_types,
|
||||
ProviderModels, create_client_config, list_client_types, oauth,
|
||||
};
|
||||
use crate::function::{FunctionDeclaration, Functions};
|
||||
use crate::rag::Rag;
|
||||
@@ -62,7 +64,7 @@ use indoc::formatdoc;
|
||||
use inquire::{Confirm, Select};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
use std::{
|
||||
env,
|
||||
@@ -140,10 +142,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 +185,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();
|
||||
|
||||
@@ -237,13 +239,18 @@ pub struct Config {
|
||||
|
||||
pub save_session: Option<bool>,
|
||||
pub compression_threshold: usize,
|
||||
pub compression_keep_last: usize,
|
||||
pub summarization_prompt: Option<String>,
|
||||
pub summary_context_prompt: Option<String>,
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
|
||||
pub memory: Option<bool>,
|
||||
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,
|
||||
@@ -258,6 +265,7 @@ pub struct Config {
|
||||
pub document_loaders: HashMap<String, String>,
|
||||
|
||||
pub highlight: bool,
|
||||
pub raw_markdown: bool,
|
||||
pub theme: Option<String>,
|
||||
pub left_prompt: Option<String>,
|
||||
pub right_prompt: Option<String>,
|
||||
@@ -312,13 +320,18 @@ impl Default for Config {
|
||||
|
||||
save_session: None,
|
||||
compression_threshold: 4000,
|
||||
compression_keep_last: 0,
|
||||
summarization_prompt: None,
|
||||
summary_context_prompt: None,
|
||||
max_tool_result_chars: None,
|
||||
|
||||
memory: None,
|
||||
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,
|
||||
@@ -332,6 +345,7 @@ impl Default for Config {
|
||||
document_loaders: Default::default(),
|
||||
|
||||
highlight: true,
|
||||
raw_markdown: false,
|
||||
theme: None,
|
||||
left_prompt: None,
|
||||
right_prompt: None,
|
||||
@@ -474,7 +488,7 @@ fn confirm_asset_overwrite(category: AssetCategory, label: &str, target: &Path)
|
||||
pub fn default_sessions_dir() -> PathBuf {
|
||||
match env::var(get_env_name("sessions_dir")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
Err(_) => paths::local_path(SESSIONS_DIR_NAME),
|
||||
Err(_) => paths::local_dir(SESSIONS_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +603,18 @@ impl Config {
|
||||
})
|
||||
.with_context(|| "Failed to load config from str")?;
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
for cc in &config.clients {
|
||||
let (name, _, _) = oauth::client_config_info(cc);
|
||||
if !seen.insert(name.to_string()) {
|
||||
bail!(
|
||||
"Duplicate client name '{name}' in config.yaml. \
|
||||
Client names must be unique across all `clients[]` entries \
|
||||
to avoid OAuth token collisions."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
+200
-58
@@ -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;
|
||||
@@ -30,11 +30,11 @@ pub fn config_dir() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_path(name: &str) -> PathBuf {
|
||||
pub fn local_dir(name: &str) -> PathBuf {
|
||||
config_dir().join(name)
|
||||
}
|
||||
|
||||
pub fn cache_path() -> PathBuf {
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
if let Ok(v) = env::var(get_env_name("cache_dir")) {
|
||||
PathBuf::from(v)
|
||||
} else if let Ok(v) = env::var("XDG_CACHE_HOME") {
|
||||
@@ -49,7 +49,7 @@ pub fn sandbox_kit_override() -> Option<PathBuf> {
|
||||
env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub fn translate_sandboxed_home_path(path: &Path) -> Option<PathBuf> {
|
||||
pub fn translate_sandboxed_home_dir(path: &Path) -> Option<PathBuf> {
|
||||
env::var_os("IS_SANDBOX")?;
|
||||
|
||||
let s = path.to_str()?;
|
||||
@@ -62,7 +62,7 @@ pub fn translate_sandboxed_home_path(path: &Path) -> Option<PathBuf> {
|
||||
return Some(translated);
|
||||
}
|
||||
|
||||
translate_windows_users_path(s)
|
||||
translate_windows_users_dir(s)
|
||||
}
|
||||
|
||||
fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
|
||||
@@ -83,7 +83,7 @@ fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
fn translate_windows_users_path(s: &str) -> Option<PathBuf> {
|
||||
fn translate_windows_users_dir(s: &str) -> Option<PathBuf> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() < 4 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' {
|
||||
return None;
|
||||
@@ -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);
|
||||
@@ -128,20 +128,20 @@ pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn oauth_tokens_path() -> PathBuf {
|
||||
cache_path().join("oauth")
|
||||
pub fn oauth_tokens_dir() -> PathBuf {
|
||||
cache_dir().join("oauth")
|
||||
}
|
||||
|
||||
pub fn token_file(client_name: &str) -> PathBuf {
|
||||
oauth_tokens_path().join(format!("{client_name}_oauth_tokens.json"))
|
||||
oauth_tokens_dir().join(format!("{client_name}_oauth_tokens.json"))
|
||||
}
|
||||
|
||||
pub fn log_path() -> PathBuf {
|
||||
cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
|
||||
pub fn log_file() -> PathBuf {
|
||||
cache_dir().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
|
||||
}
|
||||
|
||||
pub fn sbx_kit_dir() -> PathBuf {
|
||||
cache_path().join(SBX_KIT_DIR_NAME)
|
||||
cache_dir().join(SBX_KIT_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn sbx_kit_hash_file() -> PathBuf {
|
||||
@@ -149,7 +149,7 @@ pub fn sbx_kit_hash_file() -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn sbx_vault_mixins_dir() -> PathBuf {
|
||||
cache_path().join(SBX_VAULT_MIXINS_DIR_NAME)
|
||||
cache_dir().join(SBX_VAULT_MIXINS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn sbx_vault_mixins_hash_file() -> PathBuf {
|
||||
@@ -157,20 +157,20 @@ pub fn sbx_vault_mixins_hash_file() -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn sbx_mixin_kits_dir() -> PathBuf {
|
||||
cache_path().join(SBX_MIXIN_KITS_DIR_NAME)
|
||||
cache_dir().join(SBX_MIXIN_KITS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn config_file() -> PathBuf {
|
||||
match env::var(get_env_name("config_file")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
Err(_) => local_path(CONFIG_FILE_NAME),
|
||||
Err(_) => local_dir(CONFIG_FILE_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn roles_dir() -> PathBuf {
|
||||
match env::var(get_env_name("roles_dir")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
Err(_) => local_path(ROLES_DIR_NAME),
|
||||
Err(_) => local_dir(ROLES_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ pub fn role_file(name: &str) -> PathBuf {
|
||||
pub fn skills_dir() -> PathBuf {
|
||||
match env::var(get_env_name("skills_dir")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
Err(_) => local_path(SKILLS_DIR_NAME),
|
||||
Err(_) => local_dir(SKILLS_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,40 @@ pub fn skill_file(name: &str) -> PathBuf {
|
||||
skill_dir(name).join("SKILL.md")
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -209,7 +243,7 @@ pub fn validate_skill_name(name: &str) -> Result<()> {
|
||||
pub fn macros_dir() -> PathBuf {
|
||||
match env::var(get_env_name("macros_dir")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
Err(_) => local_path(MACROS_DIR_NAME),
|
||||
Err(_) => local_dir(MACROS_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,21 +254,21 @@ pub fn macro_file(name: &str) -> PathBuf {
|
||||
pub fn env_file() -> PathBuf {
|
||||
match env::var(get_env_name("env_file")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
Err(_) => local_path(ENV_FILE_NAME),
|
||||
Err(_) => local_dir(ENV_FILE_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rags_dir() -> PathBuf {
|
||||
match env::var(get_env_name("rags_dir")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
Err(_) => local_path(RAGS_DIR_NAME),
|
||||
Err(_) => local_dir(RAGS_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn functions_dir() -> PathBuf {
|
||||
match env::var(get_env_name("functions_dir")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
Err(_) => local_path(FUNCTIONS_DIR_NAME),
|
||||
Err(_) => local_dir(FUNCTIONS_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +293,7 @@ pub fn bash_prompt_utils_file() -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn agents_data_dir() -> PathBuf {
|
||||
local_path(AGENTS_DIR_NAME)
|
||||
local_dir(AGENTS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn agent_data_dir(name: &str) -> PathBuf {
|
||||
@@ -305,25 +339,29 @@ pub fn agent_functions_file(name: &str) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
pub fn models_override_file() -> PathBuf {
|
||||
local_path("models-override.yaml")
|
||||
local_dir("models-override.yaml")
|
||||
}
|
||||
|
||||
pub fn global_memory_dir() -> PathBuf {
|
||||
config_dir().join(MEMORY_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn global_memory_index_path() -> PathBuf {
|
||||
pub fn global_memory_index_file() -> PathBuf {
|
||||
global_memory_dir().join(MEMORY_INDEX_FILE_NAME)
|
||||
}
|
||||
|
||||
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_file_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)
|
||||
cache_dir().join(REPL_HISTORY_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
|
||||
@@ -346,7 +384,7 @@ pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
|
||||
});
|
||||
let resolved_log_path = match env::var(get_env_name("log_path")) {
|
||||
Ok(v) => Some(PathBuf::from(v)),
|
||||
Err(_) => Some(log_path()),
|
||||
Err(_) => Some(log_file()),
|
||||
};
|
||||
Ok((log_level, resolved_log_path))
|
||||
}
|
||||
@@ -405,15 +443,21 @@ 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()) {
|
||||
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()
|
||||
&& entry.path().join("SKILL.md").is_file()
|
||||
&& validate_skill_name(name).is_ok()
|
||||
{
|
||||
names.push(name.to_string());
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,7 +467,7 @@ pub fn list_skills() -> Vec<String> {
|
||||
}
|
||||
|
||||
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>> {
|
||||
@@ -527,7 +571,7 @@ mod tests {
|
||||
fn returns_none_when_not_in_sandbox() {
|
||||
without_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,7 +581,7 @@ mod tests {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
translate_sandboxed_home_dir(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
@@ -545,11 +589,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_nested_host_home_path() {
|
||||
fn translates_nested_host_home_dir() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.config/coyote/.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
translate_sandboxed_home_dir(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
@@ -560,7 +604,7 @@ mod tests {
|
||||
fn returns_none_when_path_already_targets_agent_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/agent/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -569,7 +613,7 @@ mod tests {
|
||||
fn returns_none_when_path_is_outside_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/etc/coyote/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -578,7 +622,7 @@ mod tests {
|
||||
fn returns_none_for_relative_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new(".coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,17 +631,17 @@ mod tests {
|
||||
fn returns_none_for_first_segment_not_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/opt/atusa/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_macos_users_path() {
|
||||
fn translates_macos_users_dir() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/atusa/.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
translate_sandboxed_home_dir(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
@@ -605,11 +649,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_macos_nested_path() {
|
||||
fn translates_macos_nested_dir() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/atusa/.config/coyote/.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
translate_sandboxed_home_dir(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
@@ -617,10 +661,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_macos_path_already_targets_agent() {
|
||||
fn returns_none_when_macos_dir_already_targets_agent() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/agent/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -630,7 +674,7 @@ mod tests {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("C:\\Users\\atusa\\.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
translate_sandboxed_home_dir(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
@@ -642,7 +686,7 @@ mod tests {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
translate_sandboxed_home_dir(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
@@ -653,7 +697,105 @@ mod tests {
|
||||
fn returns_none_when_windows_path_already_targets_agent() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("C:\\Users\\agent\\.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod workspace_mcp_resolution {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
fn with_workspace_dir<F: FnOnce(&Path, &Path)>(f: F) {
|
||||
let unique = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-workspace-mcp-test-{unique}"));
|
||||
let ws_dir = root.join(WORKSPACE_COYOTE_DIR_NAME);
|
||||
fs::create_dir_all(&ws_dir).unwrap();
|
||||
let env_name = get_env_name("workspace_config_dir");
|
||||
let prev = env::var_os(&env_name);
|
||||
unsafe {
|
||||
env::set_var(&env_name, &ws_dir);
|
||||
}
|
||||
f(&root, &ws_dir);
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var(&env_name, v),
|
||||
None => env::remove_var(&env_name),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_no_config_exists() {
|
||||
with_workspace_dir(|root, _| {
|
||||
assert_eq!(workspace_mcp_config_file_in(root), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn finds_mcp_json() {
|
||||
with_workspace_dir(|root, ws_dir| {
|
||||
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(ws_dir.join("mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn falls_back_to_claude_style_hidden_mcp_json() {
|
||||
with_workspace_dir(|root, ws_dir| {
|
||||
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(ws_dir.join(".mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn prefers_mcp_json_when_both_exist() {
|
||||
with_workspace_dir(|root, ws_dir| {
|
||||
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
|
||||
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(ws_dir.join("mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn falls_back_to_project_root_hidden_mcp_json() {
|
||||
with_workspace_dir(|root, _| {
|
||||
fs::write(root.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(root.join(".mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn prefers_workspace_dir_config_over_project_root() {
|
||||
with_workspace_dir(|root, ws_dir| {
|
||||
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||
fs::write(root.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(ws_dir.join(".mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,8 @@ pub(in crate::config) const DEFAULT_SPAWN_INSTRUCTIONS: &str = indoc! {"
|
||||
| `agent__spawn` | Spawn a subagent in the background. Returns an `id` immediately. |
|
||||
| `agent__check` | Non-blocking check: is the agent done yet? Returns PENDING or result. |
|
||||
| `agent__collect` | Blocking wait: wait for an agent to finish, return its output. |
|
||||
| `agent__list` | List all spawned agents and their status. |
|
||||
| `agent__list_available` | List all agent types you can spawn (name + description). Use this to discover specialists before calling `agent__spawn`. |
|
||||
| `agent__list_running` | List all subagents YOU have spawned, with their status. |
|
||||
| `agent__cancel` | Cancel a running agent by ID. |
|
||||
| `agent__task_create` | Create a task in the dependency-aware task queue. |
|
||||
| `agent__task_list` | List all tasks and their status/dependencies. |
|
||||
|
||||
+954
-23
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
+65
-9
@@ -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",
|
||||
@@ -175,6 +177,14 @@ impl Session {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn set_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
|
||||
pub fn clear_autoname(&mut self) {
|
||||
self.autoname = None;
|
||||
}
|
||||
|
||||
pub fn role_name(&self) -> Option<&str> {
|
||||
self.role_name.as_deref()
|
||||
}
|
||||
@@ -261,7 +271,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)
|
||||
}
|
||||
|
||||
@@ -358,14 +368,24 @@ impl Session {
|
||||
for message in &self.messages {
|
||||
match message.role {
|
||||
MessageRole::System => {
|
||||
lines.push(
|
||||
render
|
||||
.render(&message.content.render_input(resolve_url_fn, agent_info)),
|
||||
);
|
||||
let body = render
|
||||
.render(&message.content.render_input(resolve_url_fn, agent_info));
|
||||
let tail = render.finalize();
|
||||
if tail.is_empty() {
|
||||
lines.push(body);
|
||||
} else {
|
||||
lines.push(format!("{body}\n{tail}"));
|
||||
}
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
if let MessageContent::Text(text) = &message.content {
|
||||
lines.push(render.render(text));
|
||||
let body = render.render(text);
|
||||
let tail = render.finalize();
|
||||
if tail.is_empty() {
|
||||
lines.push(body);
|
||||
} else {
|
||||
lines.push(format!("{body}\n{tail}"));
|
||||
}
|
||||
}
|
||||
lines.push("".into());
|
||||
}
|
||||
@@ -401,6 +421,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();
|
||||
@@ -549,7 +570,7 @@ impl Session {
|
||||
self.compressing = compressing;
|
||||
}
|
||||
|
||||
pub fn compress(&mut self, mut prompt: String) {
|
||||
pub fn compress(&mut self, mut prompt: String, keep_last: usize) {
|
||||
if let Some(system_prompt) = self.messages.first().and_then(|v| {
|
||||
if MessageRole::System == v.role {
|
||||
let content = v.content.to_text();
|
||||
@@ -561,11 +582,17 @@ impl Session {
|
||||
}) {
|
||||
prompt = format!("{system_prompt}\n\n{prompt}",);
|
||||
}
|
||||
let messages_to_keep = if keep_last > 0 && keep_last < self.messages.len() {
|
||||
self.messages.split_off(self.messages.len() - keep_last)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
self.compressed_messages.append(&mut self.messages);
|
||||
self.messages.push(Message::new(
|
||||
MessageRole::System,
|
||||
MessageContent::Text(prompt),
|
||||
));
|
||||
self.messages.extend(messages_to_keep);
|
||||
self.dirty = true;
|
||||
self.update_tokens();
|
||||
}
|
||||
@@ -732,6 +759,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 +819,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 +854,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;
|
||||
@@ -991,7 +1038,7 @@ mod tests {
|
||||
assert_eq!(session.messages.len(), 2);
|
||||
assert!(session.compressed_messages.is_empty());
|
||||
|
||||
session.compress("Summary of conversation".to_string());
|
||||
session.compress("Summary of conversation".to_string(), 0);
|
||||
|
||||
assert!(!session.compressed_messages.is_empty());
|
||||
assert_eq!(session.messages.len(), 1);
|
||||
@@ -1006,7 +1053,7 @@ mod tests {
|
||||
MessageContent::Text("hello".to_string()),
|
||||
));
|
||||
|
||||
session.compress("Summary".to_string());
|
||||
session.compress("Summary".to_string(), 0);
|
||||
|
||||
assert!(!session.is_empty());
|
||||
}
|
||||
@@ -1023,4 +1070,13 @@ mod tests {
|
||||
session.set_autonaming(true);
|
||||
assert!(!session.need_autoname());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_set_name_updates_name() {
|
||||
let mut session = Session::default();
|
||||
|
||||
session.set_name("my-fork".to_string());
|
||||
|
||||
assert_eq!(session.name(), "my-fork");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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))
|
||||
|
||||
+20
-34
@@ -321,7 +321,7 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
|
||||
|
||||
Ok(json!({
|
||||
"files": entries,
|
||||
"global_index_exists": paths::global_memory_index_path().exists(),
|
||||
"global_index_exists": paths::global_memory_index_file().exists(),
|
||||
"workspace": store.workspace.as_ref().map(workspace_label),
|
||||
}))
|
||||
}
|
||||
@@ -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,17 +539,11 @@ 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() {
|
||||
out.push(index);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
+103
-20
@@ -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() {
|
||||
@@ -193,18 +184,65 @@ pub async fn eval_tool_calls(
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let max_chars = ctx
|
||||
.agent
|
||||
.as_ref()
|
||||
.and_then(|a| a.max_tool_result_chars())
|
||||
.or_else(|| ctx.app.config.max_tool_result_chars);
|
||||
if let Some(max_chars) = max_chars.filter(|&n| n > 0) {
|
||||
output = output
|
||||
.into_iter()
|
||||
.map(|r| r.truncate_if_needed(max_chars))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
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![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn truncate_if_needed(mut self, max_chars: usize) -> Self {
|
||||
let s = self.output.to_string();
|
||||
if s.len() > max_chars {
|
||||
let prefix = s.get(..max_chars).unwrap_or(s.as_str());
|
||||
self.output = json!(format!(
|
||||
"[truncated: tool output exceeded {max_chars} chars]\n{prefix}"
|
||||
));
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,7 +768,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 +779,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 +908,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 +919,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 +1565,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()));
|
||||
@@ -1765,7 +1818,8 @@ mod tests {
|
||||
assert!(f.contains("agent__spawn"));
|
||||
assert!(f.contains("agent__check"));
|
||||
assert!(f.contains("agent__collect"));
|
||||
assert!(f.contains("agent__list"));
|
||||
assert!(f.contains("agent__list_running"));
|
||||
assert!(f.contains("agent__list_available"));
|
||||
assert!(f.contains("agent__cancel"));
|
||||
assert!(f.contains("agent__reply_escalation"));
|
||||
}
|
||||
@@ -1890,4 +1944,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());
|
||||
}
|
||||
}
|
||||
|
||||
+142
-14
@@ -1,6 +1,8 @@
|
||||
use super::{FunctionDeclaration, JsonSchema};
|
||||
use crate::client::{Model, ModelType, call_chat_completions};
|
||||
use crate::config::{Agent, AppState, Input, RequestContext, Role, RoleLike};
|
||||
use crate::config::{
|
||||
Agent, AppState, Input, RequestContext, Role, RoleLike, list_agents_with_descriptions,
|
||||
};
|
||||
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
|
||||
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor};
|
||||
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal};
|
||||
@@ -23,6 +25,13 @@ pub const SUPERVISOR_FUNCTION_PREFIX: &str = "agent__";
|
||||
|
||||
pub const PENDING_AGENTS_GUARDRAIL_MAX: u32 = 3;
|
||||
|
||||
fn agent_permitted(whitelist: Option<&[String]>, target: &str) -> bool {
|
||||
match whitelist {
|
||||
None => true,
|
||||
Some(w) => w.iter().any(|a| a == target),
|
||||
}
|
||||
}
|
||||
|
||||
pub enum GuardrailAction {
|
||||
NoAction,
|
||||
Inject(String),
|
||||
@@ -193,8 +202,23 @@ pub fn supervisor_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
agent: false,
|
||||
},
|
||||
FunctionDeclaration {
|
||||
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list"),
|
||||
description: "List all currently running subagents and their status.".to_string(),
|
||||
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list_running"),
|
||||
description: "List all subagents YOU have spawned that are still tracked by the supervisor, with their \
|
||||
status. Use this to see which of your background agents are still active. To discover which \
|
||||
agent types you can spawn in the first place, use `agent__list_available` instead.".to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::new()),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
},
|
||||
FunctionDeclaration {
|
||||
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list_available"),
|
||||
description: "List all agent types installed and available to spawn (name + description). Use this to \
|
||||
discover what specialists exist before calling `agent__spawn` — especially when you're unsure \
|
||||
which agent to delegate to. This is the discovery counterpart to `agent__list_running` \
|
||||
(which reports agents you have already spawned).".to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::new()),
|
||||
@@ -384,7 +408,8 @@ pub async fn handle_supervisor_tool(
|
||||
"spawn" => handle_spawn(ctx, args).await,
|
||||
"check" => handle_check(ctx, args).await,
|
||||
"collect" => handle_collect(ctx, args).await,
|
||||
"list" => handle_list(ctx),
|
||||
"list_running" => handle_list_running(ctx),
|
||||
"list_available" => handle_list_available(ctx),
|
||||
"cancel" => handle_cancel(ctx, args).await,
|
||||
"send_message" => handle_send_message(ctx, args),
|
||||
"check_inbox" => handle_check_inbox(ctx),
|
||||
@@ -624,6 +649,18 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
.to_string();
|
||||
let _task_id = args.get("task_id").and_then(Value::as_str);
|
||||
|
||||
if let Some(parent) = ctx.agent.as_ref()
|
||||
&& !agent_permitted(parent.spawnable_agents(), &agent_name)
|
||||
{
|
||||
let whitelist = parent.spawnable_agents().unwrap_or_default();
|
||||
return Ok(json!({
|
||||
"status": "error",
|
||||
"message": format!(
|
||||
"Agent '{agent_name}' is not in this agent's `spawnable_agents` whitelist. Allowed: {whitelist:?}. Call `agent__list_available` to see what you can spawn."
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
let short_uuid = &Uuid::new_v4().to_string()[..8];
|
||||
let agent_id = format!("agent_{agent_name}_{short_uuid}");
|
||||
|
||||
@@ -920,7 +957,7 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_list(ctx: &mut RequestContext) -> Result<Value> {
|
||||
fn handle_list_running(ctx: &mut RequestContext) -> Result<Value> {
|
||||
let supervisor = ctx
|
||||
.supervisor
|
||||
.as_ref()
|
||||
@@ -948,6 +985,35 @@ fn handle_list(ctx: &mut RequestContext) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_list_available(ctx: &RequestContext) -> Result<Value> {
|
||||
let whitelist: Option<Vec<String>> = ctx
|
||||
.agent
|
||||
.as_ref()
|
||||
.and_then(|a| a.spawnable_agents())
|
||||
.map(<[String]>::to_vec);
|
||||
|
||||
let entries: Vec<(String, String)> = list_agents_with_descriptions()
|
||||
.into_iter()
|
||||
.filter(|(name, _)| agent_permitted(whitelist.as_deref(), name))
|
||||
.collect();
|
||||
let count = entries.len();
|
||||
let agents: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.map(|(name, description)| {
|
||||
if description.is_empty() {
|
||||
json!({ "name": name })
|
||||
} else {
|
||||
json!({ "name": name, "description": description })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"count": count,
|
||||
"agents": agents,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
let id = args
|
||||
.get("id")
|
||||
@@ -1380,6 +1446,7 @@ mod tests {
|
||||
use crate::config::{AppState, WorkingMode};
|
||||
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
||||
use serde_json::json;
|
||||
use serial_test::serial;
|
||||
|
||||
fn default_app_state() -> Arc<AppState> {
|
||||
Arc::new(AppState::test_default())
|
||||
@@ -1434,32 +1501,76 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_list_empty_supervisor() {
|
||||
fn handle_list_running_empty_supervisor() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
let result = handle_list(&mut ctx).unwrap();
|
||||
let result = handle_list_running(&mut ctx).unwrap();
|
||||
assert_eq!(result["active_count"], 0);
|
||||
assert_eq!(result["max_concurrent"], 4);
|
||||
assert!(result["agents"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_list_with_agents() {
|
||||
fn handle_list_running_with_agents() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
register_fake_agent(&mut ctx, "a1", "explore");
|
||||
register_fake_agent(&mut ctx, "a2", "coder");
|
||||
let result = handle_list(&mut ctx).unwrap();
|
||||
let result = handle_list_running(&mut ctx).unwrap();
|
||||
assert_eq!(result["active_count"], 2);
|
||||
let agents = result["agents"].as_array().unwrap();
|
||||
assert_eq!(agents.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_list_no_supervisor_errors() {
|
||||
fn handle_list_running_no_supervisor_errors() {
|
||||
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
||||
let result = handle_list(&mut ctx);
|
||||
let result = handle_list_running(&mut ctx);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_list_available_returns_shape() {
|
||||
let ctx = ctx_with_supervisor(4, 3);
|
||||
|
||||
let result = handle_list_available(&ctx).unwrap();
|
||||
|
||||
assert!(result["count"].is_number());
|
||||
assert!(result["agents"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn handle_list_available_unrestricted_when_no_whitelist() {
|
||||
let ctx = ctx_with_supervisor(4, 3);
|
||||
let result = handle_list_available(&ctx).unwrap();
|
||||
|
||||
let full_count = result["count"].as_u64().unwrap();
|
||||
|
||||
assert_eq!(full_count as usize, list_agents_with_descriptions().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_permitted_none_whitelist_allows_all() {
|
||||
assert!(agent_permitted(None, "explore"));
|
||||
assert!(agent_permitted(None, "anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_permitted_empty_whitelist_denies_all() {
|
||||
let empty: Vec<String> = vec![];
|
||||
|
||||
assert!(!agent_permitted(Some(&empty), "explore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_permitted_named_whitelist_matches_exact() {
|
||||
let allowed = vec!["explore".to_string(), "coder".to_string()];
|
||||
|
||||
assert!(agent_permitted(Some(&allowed), "explore"));
|
||||
assert!(agent_permitted(Some(&allowed), "coder"));
|
||||
assert!(!agent_permitted(Some(&allowed), "oracle"));
|
||||
assert!(!agent_permitted(Some(&allowed), "Explore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_check_unknown_agent() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
@@ -1753,13 +1864,30 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_routes_list() {
|
||||
fn dispatch_routes_list_running() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
let result =
|
||||
run_async(handle_supervisor_tool(&mut ctx, "agent__list", &json!({}))).unwrap();
|
||||
let result = run_async(handle_supervisor_tool(
|
||||
&mut ctx,
|
||||
"agent__list_running",
|
||||
&json!({}),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(result["active_count"].is_number());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_routes_list_available() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
let result = run_async(handle_supervisor_tool(
|
||||
&mut ctx,
|
||||
"agent__list_available",
|
||||
&json!({}),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(result["count"].is_number());
|
||||
assert!(result["agents"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_routes_task_list() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+85
-30
@@ -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,
|
||||
@@ -362,9 +367,21 @@ async fn run(
|
||||
if cli.no_stream {
|
||||
update_app_config(&mut ctx, |app| app.stream = false);
|
||||
}
|
||||
if cli.raw_markdown {
|
||||
update_app_config(&mut ctx, |app| app.raw_markdown = true);
|
||||
}
|
||||
if cli.no_memory {
|
||||
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()?;
|
||||
}
|
||||
@@ -374,13 +391,17 @@ async fn run(
|
||||
if let Some(scope) = cli.init_memory {
|
||||
let (path, content) = match scope {
|
||||
MemoryScope::Global => (
|
||||
paths::global_memory_index_path(),
|
||||
paths::global_memory_index_file(),
|
||||
"# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
|
||||
),
|
||||
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_file_for(&root),
|
||||
"# Workspace Memory Index\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if path.exists() {
|
||||
@@ -393,9 +414,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 +605,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);
|
||||
@@ -726,28 +772,37 @@ fn resolve_oauth_client(
|
||||
explicit: Option<&str>,
|
||||
clients: &[ClientConfig],
|
||||
) -> Result<(String, Box<dyn OAuthProvider>)> {
|
||||
if let Some(name) = explicit {
|
||||
let provider_type = oauth::resolve_provider_type(name, clients)
|
||||
.ok_or_else(|| anyhow!("Client '{name}' not found or doesn't support OAuth"))?;
|
||||
let provider = oauth::get_oauth_provider(provider_type).unwrap();
|
||||
return Ok((name.to_string(), provider));
|
||||
}
|
||||
let find_by_name = |name: &str| -> Option<&ClientConfig> {
|
||||
clients.iter().find(|cc| {
|
||||
let (n, _, auth) = oauth::client_config_info(cc);
|
||||
n == name && auth == Some("oauth")
|
||||
})
|
||||
};
|
||||
|
||||
let candidates = oauth::list_oauth_capable_clients(clients);
|
||||
match candidates.len() {
|
||||
0 => bail!("No OAuth-capable clients configured."),
|
||||
1 => {
|
||||
let name = &candidates[0];
|
||||
let provider_type = oauth::resolve_provider_type(name, clients).unwrap();
|
||||
let provider = oauth::get_oauth_provider(provider_type).unwrap();
|
||||
Ok((name.clone(), provider))
|
||||
let target = if let Some(name) = explicit {
|
||||
find_by_name(name)
|
||||
.ok_or_else(|| anyhow!("Client '{name}' not found or doesn't support OAuth"))?
|
||||
} else {
|
||||
let candidates = oauth::list_oauth_capable_clients(clients);
|
||||
match candidates.len() {
|
||||
0 => bail!("No OAuth-capable clients configured."),
|
||||
1 => find_by_name(&candidates[0]).unwrap(),
|
||||
_ => {
|
||||
let choice =
|
||||
Select::new("Select a client to authenticate:", candidates.clone()).prompt()?;
|
||||
find_by_name(&choice)
|
||||
.ok_or_else(|| anyhow!("Selected client '{choice}' not found"))?
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let choice =
|
||||
Select::new("Select a client to authenticate:", candidates.clone()).prompt()?;
|
||||
let provider_type = oauth::resolve_provider_type(&choice, clients).unwrap();
|
||||
let provider = oauth::get_oauth_provider(provider_type).unwrap();
|
||||
Ok((choice, provider))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let name = oauth::client_config_info(target).0.to_string();
|
||||
let provider = oauth::get_oauth_provider_for_client(target, &client::ALL_PROVIDER_MODELS)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Could not build OAuth provider for '{name}' (no oauth config in models.yaml or user config)"
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((name, provider))
|
||||
}
|
||||
|
||||
+56
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+205
-32
@@ -14,6 +14,8 @@ use url::Url;
|
||||
struct ProtectedResourceMetadata {
|
||||
#[serde(default)]
|
||||
authorization_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
scopes_supported: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -59,8 +61,8 @@ impl OAuthProvider for McpOAuthProvider {
|
||||
""
|
||||
}
|
||||
|
||||
fn scopes(&self) -> &str {
|
||||
&self.scopes
|
||||
fn scopes(&self) -> String {
|
||||
self.scopes.clone()
|
||||
}
|
||||
|
||||
fn token_request_format(&self) -> TokenRequestFormat {
|
||||
@@ -140,7 +142,7 @@ fn mcp_token_key(server_name: &str) -> String {
|
||||
}
|
||||
|
||||
fn load_registered_client_id(server_name: &str) -> Option<String> {
|
||||
let path = paths::oauth_tokens_path().join(format!("mcp_{server_name}_registration.json"));
|
||||
let path = paths::oauth_tokens_dir().join(format!("mcp_{server_name}_registration.json"));
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
let reg: McpRegistration = serde_json::from_str(&content).ok()?;
|
||||
|
||||
@@ -148,7 +150,7 @@ fn load_registered_client_id(server_name: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
|
||||
let dir = paths::oauth_tokens_path();
|
||||
let dir = paths::oauth_tokens_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let path = dir.join(format!("mcp_{server_name}_registration.json"));
|
||||
@@ -187,46 +189,122 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
||||
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");
|
||||
if let Ok(resp) = client.get(&as_url).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
|
||||
{
|
||||
return Ok(meta);
|
||||
// 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(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() {
|
||||
return resp
|
||||
.json::<OAuthServerMetadata>()
|
||||
.await
|
||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
||||
// 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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
+643
-21
@@ -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) {
|
||||
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);
|
||||
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 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+138
-41
@@ -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 {
|
||||
break 'outer;
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2072
-12
File diff suppressed because it is too large
Load Diff
+35
-17
@@ -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},
|
||||
@@ -42,18 +42,21 @@ pub async fn raw_stream(
|
||||
if abort_signal.aborted() {
|
||||
break;
|
||||
}
|
||||
if let Some(evt) = rx.recv().await {
|
||||
if let Some(spinner) = spinner.take() {
|
||||
spinner.stop();
|
||||
}
|
||||
|
||||
match evt {
|
||||
SseEvent::Text(text) => {
|
||||
print!("{text}");
|
||||
stdout().flush()?;
|
||||
match rx.recv().await {
|
||||
None => break,
|
||||
Some(evt) => {
|
||||
if let Some(spinner) = spinner.take() {
|
||||
spinner.stop();
|
||||
}
|
||||
SseEvent::Done => {
|
||||
break;
|
||||
|
||||
match evt {
|
||||
SseEvent::Text(text) => {
|
||||
print!("{text}");
|
||||
stdout().flush()?;
|
||||
}
|
||||
SseEvent::Done => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +77,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 +99,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),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,7 +135,9 @@ async fn markdown_stream_inner(
|
||||
let text = format!("{buffer}{text}");
|
||||
let (head, tail) = split_line_tail(&text);
|
||||
let output = render.render(head);
|
||||
print_block(writer, &output, columns)?;
|
||||
if !output.is_empty() {
|
||||
print_block(writer, &output, columns)?;
|
||||
}
|
||||
buffer = tail.to_string();
|
||||
} else {
|
||||
buffer = format!("{buffer}{text}");
|
||||
@@ -142,10 +156,14 @@ async fn markdown_stream_inner(
|
||||
queue!(writer, style::Print(&output))?;
|
||||
buffer_rows = need_rows(&output, columns);
|
||||
}
|
||||
|
||||
writer.flush()?;
|
||||
}
|
||||
SseEvent::Done => {
|
||||
let tail = render.finalize();
|
||||
if !tail.is_empty() {
|
||||
queue!(writer, style::Print("\n"), style::Print(&tail))?;
|
||||
writer.flush()?;
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+148
-90
@@ -1,15 +1,13 @@
|
||||
mod completer;
|
||||
mod highlighter;
|
||||
mod prompt;
|
||||
mod replay;
|
||||
|
||||
use self::completer::ReplCompleter;
|
||||
use self::highlighter::ReplHighlighter;
|
||||
use self::prompt::ReplPrompt;
|
||||
|
||||
use crate::client::{
|
||||
Message, MessageRole, call_chat_completions, call_chat_completions_streaming, init_client,
|
||||
oauth,
|
||||
};
|
||||
use crate::client::{call_chat_completions, call_chat_completions_streaming, init_client, oauth};
|
||||
use crate::config::{
|
||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
|
||||
macro_execute,
|
||||
@@ -52,7 +50,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; 58]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
@@ -71,6 +69,26 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
|
||||
"Authenticate with an MCP server via OAuth",
|
||||
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 +143,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",
|
||||
@@ -150,6 +173,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
|
||||
"Exit active session",
|
||||
AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".fork",
|
||||
"Fork the active session into a new named copy",
|
||||
AssertState::True(StateFlags::SESSION),
|
||||
),
|
||||
ReplCommand::new(".agent", "Use an agent", AssertState::bare()),
|
||||
ReplCommand::new(
|
||||
".starter",
|
||||
@@ -254,11 +282,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",
|
||||
@@ -322,54 +360,16 @@ Type ".help" for additional help.
|
||||
}
|
||||
|
||||
{
|
||||
let (messages_snapshot, compressed_count) = {
|
||||
let (compressed, active) = {
|
||||
let ctx = self.ctx.read();
|
||||
if let Some(session) = &ctx.session {
|
||||
let msgs: Vec<Message> = session
|
||||
.messages()
|
||||
.iter()
|
||||
.filter(|m| !m.role.is_system())
|
||||
.cloned()
|
||||
.collect();
|
||||
let compressed = session.compressed_messages().len();
|
||||
(msgs, compressed)
|
||||
} else {
|
||||
(vec![], 0)
|
||||
match &ctx.session {
|
||||
Some(session) => replay::snapshot(session),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
}
|
||||
};
|
||||
|
||||
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
||||
if !compressed.is_empty() || !active.is_empty() {
|
||||
let app = Arc::clone(&self.ctx.read().app.config);
|
||||
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!();
|
||||
replay::render(app.as_ref(), &compressed, &active)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +390,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 +663,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) => {
|
||||
@@ -760,44 +819,8 @@ pub async fn run_repl_command(
|
||||
}
|
||||
}
|
||||
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!();
|
||||
}
|
||||
let (compressed, active) = replay::snapshot(session);
|
||||
replay::render(app.as_ref(), &compressed, &active)?;
|
||||
}
|
||||
}
|
||||
".install" => {
|
||||
@@ -853,6 +876,10 @@ 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 (compressed, active) = replay::snapshot(session);
|
||||
replay::render(app.as_ref(), &compressed, &active)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#)
|
||||
@@ -884,6 +911,9 @@ pub async fn run_repl_command(
|
||||
ctx.app.config.print_markdown(&banner)?;
|
||||
}
|
||||
},
|
||||
".fork" => {
|
||||
ctx.fork_session(args)?;
|
||||
}
|
||||
".save" => match split_first_arg(args) {
|
||||
Some(("role", name)) => {
|
||||
ctx.save_role(name)?;
|
||||
@@ -966,6 +996,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 +1091,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 +1108,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 +1640,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_50_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 50);
|
||||
fn repl_commands_has_58_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 58);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::{Message, MessageRole};
|
||||
use crate::config::{AppConfig, Session};
|
||||
use crate::utils::dimmed_text;
|
||||
|
||||
pub fn snapshot(session: &Session) -> (Vec<Message>, Vec<Message>) {
|
||||
(
|
||||
filter_for_display(session.compressed_messages()),
|
||||
filter_for_display(session.messages()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render(app: &AppConfig, compressed: &[Message], active: &[Message]) -> Result<()> {
|
||||
if compressed.is_empty() && active.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
render_messages(app, compressed)?;
|
||||
if !compressed.is_empty() && !active.is_empty() {
|
||||
println!("{}", dimmed_text("─── ↑ pre-compression history ↑ ───"));
|
||||
println!();
|
||||
}
|
||||
render_messages(app, active)?;
|
||||
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn filter_for_display(messages: &[Message]) -> Vec<Message> {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| !m.role.is_system())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_messages(app: &AppConfig, messages: &[Message]) -> Result<()> {
|
||||
for message in messages {
|
||||
match message.role {
|
||||
MessageRole::User => {
|
||||
if let Some(text) = message.content.as_text() {
|
||||
println!("{}", dimmed_text("You:"));
|
||||
println!("{text}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
if let Some(text) = message.content.as_text() {
|
||||
app.print_markdown(text)?;
|
||||
println!();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -388,6 +388,27 @@ fn copy_host_files(name: &str) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let oauth_tokens_dir = paths::oauth_tokens_dir();
|
||||
if oauth_tokens_dir.exists() {
|
||||
let sandbox_cache_dir = "/home/agent/.cache";
|
||||
let sandbox_oauth_dir = "/home/agent/.cache/coyote/oauth";
|
||||
ensure_sandbox_dir(name, sandbox_oauth_dir)?;
|
||||
let dest = format!("{name}:{sandbox_oauth_dir}/");
|
||||
for entry in fs::read_dir(&oauth_tokens_dir)
|
||||
.with_context(|| format!("Failed to read {}", oauth_tokens_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
sbx_cp(&path.display().to_string(), &dest)?;
|
||||
}
|
||||
chown_agent_recursive(name, sandbox_cache_dir)?;
|
||||
} else {
|
||||
debug!(
|
||||
"Skipping OAuth token copy: {} does not exist",
|
||||
oauth_tokens_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
match resolve_vault_password_file() {
|
||||
Some(password_file) if password_file.exists() => {
|
||||
let dest_path = host_to_sandbox_path(&password_file, &home_dir, cfg!(windows))?;
|
||||
|
||||
+34
-7
@@ -58,6 +58,13 @@ impl Supervisor {
|
||||
self.handles.len()
|
||||
}
|
||||
|
||||
pub fn effective_active_count(&self) -> usize {
|
||||
self.handles
|
||||
.values()
|
||||
.filter(|h| !h.join_handle.is_finished())
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn max_concurrent(&self) -> usize {
|
||||
self.max_concurrent
|
||||
}
|
||||
@@ -75,10 +82,10 @@ impl Supervisor {
|
||||
}
|
||||
|
||||
pub fn register(&mut self, handle: AgentHandle) -> Result<()> {
|
||||
if self.handles.len() >= self.max_concurrent {
|
||||
if self.effective_active_count() >= self.max_concurrent {
|
||||
bail!(
|
||||
"Cannot spawn agent: at capacity ({}/{})",
|
||||
self.handles.len(),
|
||||
self.effective_active_count(),
|
||||
self.max_concurrent
|
||||
);
|
||||
}
|
||||
@@ -146,12 +153,11 @@ impl Debug for Supervisor {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::create_abort_signal;
|
||||
use anyhow::Error;
|
||||
use tokio::runtime::Builder;
|
||||
|
||||
fn make_handle(id: &str, agent_name: &str, depth: usize) -> AgentHandle {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let rt = Builder::new_current_thread().enable_all().build().unwrap();
|
||||
let join_handle = rt.spawn(async {
|
||||
Ok(AgentResult {
|
||||
id: "done".into(),
|
||||
@@ -188,8 +194,29 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn supervisor_register_rejects_at_capacity() {
|
||||
// Keep the runtime alive in this scope so the spawned task is never
|
||||
// polled (current_thread only polls inside block_on), keeping
|
||||
// join_handle.is_finished() == false and the slot occupied.
|
||||
let rt = Builder::new_current_thread().enable_all().build().unwrap();
|
||||
let join_handle = rt.spawn(async {
|
||||
Ok::<AgentResult, Error>(AgentResult {
|
||||
id: "done".into(),
|
||||
agent_name: "test".into(),
|
||||
output: "result".into(),
|
||||
exit_status: AgentExitStatus::Completed,
|
||||
})
|
||||
});
|
||||
let running_handle = AgentHandle {
|
||||
id: "a1".to_string(),
|
||||
agent_name: "explore".to_string(),
|
||||
depth: 1,
|
||||
inbox: Arc::new(Inbox::new()),
|
||||
abort_signal: create_abort_signal(),
|
||||
join_handle,
|
||||
child_supervisor: None,
|
||||
};
|
||||
let mut sup = Supervisor::new(1, 3);
|
||||
sup.register(make_handle("a1", "explore", 1)).unwrap();
|
||||
sup.register(running_handle).unwrap();
|
||||
let result = sup.register(make_handle("a2", "coder", 1));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("at capacity"));
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ use tokio::time::sleep;
|
||||
|
||||
pub async fn tail_logs(no_color: bool) {
|
||||
let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)\]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap();
|
||||
let file_path = paths::log_path();
|
||||
let file_path = paths::log_file();
|
||||
let file = File::open(&file_path).expect("Cannot open file");
|
||||
let mut reader = BufReader::new(file);
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ fn apply_sandboxed_home_translation(provider_def: &mut LocalProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(translated) = paths::translate_sandboxed_home_path(pf) else {
|
||||
let Some(translated) = paths::translate_sandboxed_home_dir(pf) else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user