Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump_type:
|
bump_type:
|
||||||
description: "Specify the type of version bump"
|
description: 'Specify the type of version bump'
|
||||||
required: true
|
required: true
|
||||||
default: "patch"
|
default: 'patch'
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- patch
|
- patch
|
||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: '3.10'
|
||||||
|
|
||||||
- name: Install Commitizen
|
- name: Install Commitizen
|
||||||
run: |
|
run: |
|
||||||
@@ -108,17 +108,19 @@ jobs:
|
|||||||
|
|
||||||
cargo update || true
|
cargo update || true
|
||||||
|
|
||||||
|
sed -i "s|image: 'darkalex17/coyote:v[^']*'|image: 'darkalex17/coyote:v${VERSION}'|" assets/sbx-kit/spec.yaml
|
||||||
|
|
||||||
# Git config that helps in Act
|
# Git config that helps in Act
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
git diff --name-only -- Cargo.toml Cargo.lock || true
|
git diff --name-only -- Cargo.toml Cargo.lock assets/sbx-kit/spec.yaml || true
|
||||||
|
|
||||||
if ! git diff --quiet -- Cargo.toml Cargo.lock; then
|
if ! git diff --quiet -- Cargo.toml Cargo.lock assets/sbx-kit/spec.yaml; then
|
||||||
git add -u -- Cargo.toml Cargo.lock
|
git add -u -- Cargo.toml Cargo.lock assets/sbx-kit/spec.yaml
|
||||||
git commit -m "chore: bump Cargo.toml to $VERSION"
|
git commit -m "chore: bump Cargo.toml and sandbox image to $VERSION"
|
||||||
else
|
else
|
||||||
echo "No changes to commit (already at $VERSION)"
|
echo "No changes to commit (already at $VERSION)"
|
||||||
fi
|
fi
|
||||||
@@ -163,28 +165,28 @@ jobs:
|
|||||||
- target: aarch64-unknown-linux-musl
|
- target: aarch64-unknown-linux-musl
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
use-cross: true
|
use-cross: true
|
||||||
cargo-flags: ""
|
cargo-flags: ''
|
||||||
- target: aarch64-apple-darwin
|
- target: aarch64-apple-darwin
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
use-cross: true
|
use-cross: true
|
||||||
cargo-flags: ""
|
cargo-flags: ''
|
||||||
- target: aarch64-pc-windows-msvc
|
- target: aarch64-pc-windows-msvc
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
use-cross: true
|
use-cross: true
|
||||||
cargo-flags: ""
|
cargo-flags: ''
|
||||||
- target: x86_64-apple-darwin
|
- target: x86_64-apple-darwin
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
cargo-flags: ""
|
cargo-flags: ''
|
||||||
- target: x86_64-pc-windows-msvc
|
- target: x86_64-pc-windows-msvc
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
cargo-flags: ""
|
cargo-flags: ''
|
||||||
- target: x86_64-unknown-linux-musl
|
- target: x86_64-unknown-linux-musl
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
use-cross: true
|
use-cross: true
|
||||||
cargo-flags: ""
|
cargo-flags: ''
|
||||||
- target: x86_64-unknown-linux-gnu
|
- target: x86_64-unknown-linux-gnu
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
cargo-flags: ""
|
cargo-flags: ''
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check if actor is repository owner
|
- name: Check if actor is repository owner
|
||||||
@@ -338,7 +340,7 @@ jobs:
|
|||||||
${{ steps.package.outputs.archive }}
|
${{ steps.package.outputs.archive }}
|
||||||
${{ steps.package.outputs.sha }}
|
${{ steps.package.outputs.sha }}
|
||||||
tag_name: v${{ env.RELEASE_VERSION }}
|
tag_name: v${{ env.RELEASE_VERSION }}
|
||||||
name: "v${{ env.RELEASE_VERSION }}"
|
name: 'v${{ env.RELEASE_VERSION }}'
|
||||||
body_path: artifacts/changelog.md
|
body_path: artifacts/changelog.md
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
||||||
@@ -456,3 +458,63 @@ jobs:
|
|||||||
if: env.ACT != 'true'
|
if: env.ACT != 'true'
|
||||||
with:
|
with:
|
||||||
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
publish-sandbox-image:
|
||||||
|
needs: [publish-github-release]
|
||||||
|
name: Publish Sandbox Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check if actor is repository owner
|
||||||
|
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
|
||||||
|
run: |
|
||||||
|
echo "You are not authorized to run this workflow."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Ensure repository is up-to-date
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
run: |
|
||||||
|
git fetch --all
|
||||||
|
git pull
|
||||||
|
|
||||||
|
- name: Get release artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set version variable
|
||||||
|
run: |
|
||||||
|
version="$(cat artifacts/release-version)"
|
||||||
|
echo "version=$version" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Validate release environment variables
|
||||||
|
run: |
|
||||||
|
echo "Release version: ${{ env.version }}"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Push to Docker Hub
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ env.ACT != 'true' }}
|
||||||
|
tags: darkalex17/coyote:latest, darkalex17/coyote:${{ env.version }}
|
||||||
|
build-args: COYOTE_VERSION=${{ env.version }}
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
# Graph RAG Design Spec
|
|
||||||
|
|
||||||
## Status: COMPLETE
|
|
||||||
|
|
||||||
### Verified From Code (all claims backed by actual file reads)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Extend the existing two-signal hybrid search (vector HNSW + BM25 → RRF) to a three-signal hybrid
|
|
||||||
(vector + BM25 + knowledge graph → RRF). The graph captures entity/relationship knowledge extracted
|
|
||||||
from documents at ingestion time via an LLM call per chunk. At query time, graph traversal expands
|
|
||||||
context beyond semantic similarity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verified Current Architecture
|
|
||||||
|
|
||||||
### `Rag` struct (`src/rag/mod.rs:48`)
|
|
||||||
```rust
|
|
||||||
pub struct Rag {
|
|
||||||
app_config: Arc<AppConfig>,
|
|
||||||
name: String,
|
|
||||||
path: String,
|
|
||||||
embedding_model: Model,
|
|
||||||
hnsw: Hnsw<'static, f32, DistCosine>, // ephemeral, rebuilt on load
|
|
||||||
bm25: SearchEngine<DocumentId>, // ephemeral, rebuilt on load
|
|
||||||
data: RagData, // serialized to YAML
|
|
||||||
last_sources: RwLock<Option<String>>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `RagData` struct (`src/rag/mod.rs:892`)
|
|
||||||
```rust
|
|
||||||
pub struct RagData {
|
|
||||||
pub embedding_model: String,
|
|
||||||
pub chunk_size: usize,
|
|
||||||
pub chunk_overlap: usize,
|
|
||||||
pub reranker_model: Option<String>,
|
|
||||||
pub top_k: usize,
|
|
||||||
pub batch_size: Option<usize>,
|
|
||||||
pub next_file_id: FileId,
|
|
||||||
pub document_paths: Vec<String>,
|
|
||||||
pub files: IndexMap<FileId, RagFile>,
|
|
||||||
#[serde(with = "serde_vectors")]
|
|
||||||
pub vectors: IndexMap<DocumentId, Vec<f32>>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `RagData::new` callers (both need updating):
|
|
||||||
1. `Rag::init` (`src/rag/mod.rs:219`) — interactive init path
|
|
||||||
2. `Rag::resolve_init_data` (`src/rag/mod.rs:195`) — config-driven init path
|
|
||||||
|
|
||||||
### `Rag::create` (`src/rag/mod.rs:253`) — all init paths converge here:
|
|
||||||
```rust
|
|
||||||
pub fn create(app: &AppConfig, name: &str, path: &Path, data: RagData) -> Result<Self> {
|
|
||||||
let hnsw = data.build_hnsw();
|
|
||||||
let bm25 = data.build_bm25();
|
|
||||||
let embedding_model = Model::retrieve_model(app, &data.embedding_model, ModelType::Embedding)?;
|
|
||||||
let rag = Rag { app_config: Arc::new(app.clone()), name: name.to_string(),
|
|
||||||
path: path.display().to_string(), data, embedding_model, hnsw, bm25,
|
|
||||||
last_sources: RwLock::new(None) };
|
|
||||||
Ok(rag)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `hybrid_search` (`src/rag/mod.rs:710`)
|
|
||||||
```rust
|
|
||||||
async fn hybrid_search(&self, query: &str, top_k: usize, rerank_model: Option<&str>)
|
|
||||||
-> Result<Vec<(DocumentId, String)>>
|
|
||||||
```
|
|
||||||
Runs `vector_search` + `keyword_search` in parallel via `tokio::join!`, then either reranks or
|
|
||||||
applies `reciprocal_rank_fusion(vec![vector_ids, keyword_ids], vec![1.125, 1.0], top_k)`.
|
|
||||||
|
|
||||||
### `reciprocal_rank_fusion` (`src/rag/mod.rs:1186`) — standalone fn, already weight-parameterized:
|
|
||||||
```rust
|
|
||||||
fn reciprocal_rank_fusion(
|
|
||||||
list_of_document_ids: Vec<Vec<DocumentId>>,
|
|
||||||
list_of_weights: Vec<f32>,
|
|
||||||
top_k: usize,
|
|
||||||
) -> Vec<DocumentId>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `RagData::del` (`src/rag/mod.rs:953`):
|
|
||||||
```rust
|
|
||||||
pub fn del(&mut self, file_ids: Vec<FileId>) {
|
|
||||||
for file_id in file_ids {
|
|
||||||
if let Some(file) = self.files.swap_remove(&file_id) {
|
|
||||||
for (document_index, _) in file.documents.iter().enumerate() {
|
|
||||||
let document_id = DocumentId::new(file_id, document_index);
|
|
||||||
self.vectors.swap_remove(&document_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `RagNode` (`src/graph/types.rs:331`):
|
|
||||||
```rust
|
|
||||||
pub struct RagNode {
|
|
||||||
pub documents: Vec<String>,
|
|
||||||
pub query: Option<String>,
|
|
||||||
pub top_k: Option<usize>,
|
|
||||||
pub embedding_model: Option<String>,
|
|
||||||
pub chunk_size: Option<usize>,
|
|
||||||
pub chunk_overlap: Option<usize>,
|
|
||||||
pub reranker_model: Option<String>,
|
|
||||||
pub batch_size: Option<usize>,
|
|
||||||
pub state_updates: Option<HashMap<String, String>>,
|
|
||||||
pub timeout: Option<u64>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `Client` trait (`src/client/common.rs:40`):
|
|
||||||
- `async fn chat_completions(&self, input: Input) -> Result<ChatCompletionsOutput>` — needs `Input`
|
|
||||||
- `async fn chat_completions_inner(&self, client: &ReqwestClient, data: ChatCompletionsData) -> Result<ChatCompletionsOutput>` — accessible on `Box<dyn Client>` via vtable
|
|
||||||
- `async fn embeddings(&self, data: &EmbeddingsData) -> Result<Vec<Vec<f32>>>`
|
|
||||||
- `async fn rerank(&self, data: &RerankData) -> Result<RerankOutput>`
|
|
||||||
- `fn build_client(&self) -> Result<ReqwestClient>`
|
|
||||||
- `fn model(&self) -> &Model`
|
|
||||||
|
|
||||||
**Key finding**: `Input` cannot be constructed without `RequestContext` (which `Rag` doesn't have).
|
|
||||||
Instead, `extract_entities` uses `chat_completions_inner` directly with manually built
|
|
||||||
`ChatCompletionsData`. This is accessible via `Box<dyn Client>`.
|
|
||||||
|
|
||||||
### `Message` (`src/client/message.rs:22`):
|
|
||||||
```rust
|
|
||||||
pub fn new(role: MessageRole, content: MessageContent) -> Self
|
|
||||||
```
|
|
||||||
`MessageRole::User`, `MessageContent::Text(String)` — both confirmed.
|
|
||||||
|
|
||||||
### `AppConfig` RAG fields (`src/config/app_config.rs:71`):
|
|
||||||
```rust
|
|
||||||
pub rag_embedding_model: Option<String>,
|
|
||||||
pub rag_reranker_model: Option<String>,
|
|
||||||
pub rag_top_k: usize, // default: 5
|
|
||||||
pub rag_chunk_size: Option<usize>,
|
|
||||||
pub rag_chunk_overlap: Option<usize>,
|
|
||||||
pub rag_template: Option<String>,
|
|
||||||
```
|
|
||||||
|
|
||||||
### `patch_messages` — confirmed exported from `crate::client::*` (used in `input.rs:5`)
|
|
||||||
|
|
||||||
### `init_client(app_config, model)` — works for any `ModelType`, including `Chat`
|
|
||||||
|
|
||||||
### `ModelType` variants: `Chat`, `Embedding`, `Reranker` (confirmed in `model.rs`)
|
|
||||||
|
|
||||||
### petgraph serde: `NodeIndex` serializes as inner `u32`; `StableGraph` preserves index positions
|
|
||||||
through roundtrip. `IndexMap<DocumentId, Vec<NodeIndex>>` safe for YAML (DocumentId is newtype over
|
|
||||||
usize, serializes as integer key).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Dependency
|
|
||||||
|
|
||||||
```toml
|
|
||||||
petgraph = { version = "0.7", features = ["serde-1"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New File: `src/rag/graph.rs`
|
|
||||||
|
|
||||||
All graph types and extraction logic. Module declared in `mod.rs` as `mod graph; use self::graph::*;`.
|
|
||||||
|
|
||||||
### Types:
|
|
||||||
- `Entity { name: String, entity_type: String, description: Option<String> }`
|
|
||||||
- `Relationship { relation_type: String, weight: f32 }`
|
|
||||||
- `ExtractionResult { entities: Vec<ExtractedEntity>, relationships: Vec<ExtractedRelationship> }`
|
|
||||||
- `ExtractedEntity { name: String, r#type: String, description: Option<String> }`
|
|
||||||
- `ExtractedRelationship { from: String, to: String, r#type: String, weight: Option<f32> }`
|
|
||||||
- `KnowledgeGraph { graph: StableGraph<Entity, Relationship>, entity_index: IndexMap<String, NodeIndex>, document_entities: IndexMap<DocumentId, Vec<NodeIndex>> }`
|
|
||||||
|
|
||||||
### Key methods on `KnowledgeGraph`:
|
|
||||||
- `merge(doc_id: DocumentId, result: ExtractionResult)` — merges extraction into graph
|
|
||||||
- `remove_documents(ids: &[DocumentId])` — removes entities exclusive to deleted documents
|
|
||||||
- `build_node_to_docs(&self) -> IndexMap<NodeIndex, Vec<DocumentId>>` — ephemeral reverse map
|
|
||||||
|
|
||||||
### `extract_entities(client: &dyn Client, chunk: &str) -> Result<ExtractionResult>`:
|
|
||||||
- Builds `ChatCompletionsData` manually (no `Input` needed)
|
|
||||||
- Calls `patch_messages` then `client.chat_completions_inner(&reqwest_client, data).await`
|
|
||||||
- Strips markdown code fences from response before JSON parse
|
|
||||||
- Temperature: `Some(0.0)` for deterministic extraction
|
|
||||||
|
|
||||||
### Extraction prompt: structured JSON output requesting entities + relationships
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changes to `src/rag/mod.rs`
|
|
||||||
|
|
||||||
### `Rag` struct — add one ephemeral field:
|
|
||||||
```rust
|
|
||||||
node_to_docs: IndexMap<NodeIndex, Vec<DocumentId>>, // ephemeral, rebuilt on load
|
|
||||||
```
|
|
||||||
|
|
||||||
### `Rag::create` — build node_to_docs before moving data:
|
|
||||||
```rust
|
|
||||||
let node_to_docs = data.knowledge_graph.build_node_to_docs();
|
|
||||||
// then add to struct literal
|
|
||||||
```
|
|
||||||
|
|
||||||
### `Rag` Clone impl — add:
|
|
||||||
```rust
|
|
||||||
node_to_docs: self.data.knowledge_graph.build_node_to_docs(),
|
|
||||||
```
|
|
||||||
|
|
||||||
### `RagData` struct — three new fields (all `#[serde(default)]` for backward compat):
|
|
||||||
```rust
|
|
||||||
#[serde(default)]
|
|
||||||
pub graph_enabled: bool,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub extractor_model: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub knowledge_graph: KnowledgeGraph,
|
|
||||||
```
|
|
||||||
|
|
||||||
### `RagData::new` — two new params: `graph_enabled: bool, extractor_model: Option<String>`
|
|
||||||
|
|
||||||
### `RagData::del` — collect doc_ids during existing loop, call `remove_documents` at end:
|
|
||||||
```rust
|
|
||||||
let mut doc_ids_to_remove = vec![];
|
|
||||||
for file_id in file_ids {
|
|
||||||
if let Some(file) = self.files.swap_remove(&file_id) {
|
|
||||||
for (document_index, _) in file.documents.iter().enumerate() {
|
|
||||||
let document_id = DocumentId::new(file_id, document_index);
|
|
||||||
self.vectors.swap_remove(&document_id);
|
|
||||||
doc_ids_to_remove.push(document_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.knowledge_graph.remove_documents(&doc_ids_to_remove);
|
|
||||||
```
|
|
||||||
|
|
||||||
### `Rag::init` (line 219) — add two params to `RagData::new`:
|
|
||||||
```rust
|
|
||||||
app.rag_graph_enabled,
|
|
||||||
app.rag_extractor_model.clone(),
|
|
||||||
```
|
|
||||||
|
|
||||||
### `resolve_init_data` — resolve from config+app, pass to `RagData::new`:
|
|
||||||
```rust
|
|
||||||
let graph_enabled = config.graph_enabled.unwrap_or(app.rag_graph_enabled);
|
|
||||||
let extractor_model = config.extractor_model.clone().or_else(|| app.rag_extractor_model.clone());
|
|
||||||
```
|
|
||||||
|
|
||||||
### `sync_documents` — entity extraction block after `rag_files` built, before embedding:
|
|
||||||
```rust
|
|
||||||
if self.data.graph_enabled {
|
|
||||||
if let Some(extractor_model_id) = self.data.extractor_model.clone() {
|
|
||||||
let model = Model::retrieve_model(&self.app_config, &extractor_model_id, ModelType::Chat)?;
|
|
||||||
let client = self.create_embeddings_client(model)?;
|
|
||||||
let total_chunks: usize = rag_files.iter().map(|f| f.documents.len()).sum();
|
|
||||||
let mut chunk_num = 0;
|
|
||||||
let file_offset = next_file_id;
|
|
||||||
for (batch_file_idx, rag_file) in rag_files.iter().enumerate() {
|
|
||||||
let file_id = file_offset + batch_file_idx;
|
|
||||||
for (doc_idx, doc) in rag_file.documents.iter().enumerate() {
|
|
||||||
chunk_num += 1;
|
|
||||||
progress(&spinner, format!("Extracting entities [{chunk_num}/{total_chunks}]"));
|
|
||||||
let doc_id = DocumentId::new(file_id, doc_idx);
|
|
||||||
match extract_entities(client.as_ref(), &doc.page_content).await {
|
|
||||||
Ok(result) => self.data.knowledge_graph.merge(doc_id, result),
|
|
||||||
Err(e) => debug!("Entity extraction failed for {doc_id:?}: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### After line 705 (after hnsw/bm25 rebuild in sync_documents):
|
|
||||||
```rust
|
|
||||||
self.node_to_docs = self.data.knowledge_graph.build_node_to_docs();
|
|
||||||
```
|
|
||||||
|
|
||||||
### `hybrid_search` — add third signal:
|
|
||||||
```rust
|
|
||||||
let graph_search_ids: Vec<DocumentId> = if self.data.graph_enabled
|
|
||||||
&& !self.data.knowledge_graph.entity_index.is_empty()
|
|
||||||
{
|
|
||||||
self.graph_search(query, &keyword_search_ids, top_k)
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
// RRF: extend to 3-way when graph has results, fall back to 2-way otherwise
|
|
||||||
```
|
|
||||||
|
|
||||||
### New `graph_search` method (sync):
|
|
||||||
```rust
|
|
||||||
fn graph_search(&self, query: &str, bm25_anchor_ids: &[DocumentId], top_k: usize) -> Vec<DocumentId>
|
|
||||||
```
|
|
||||||
Phase 1: entity names from query via substring match in `entity_index`.
|
|
||||||
Phase 2: fallback — entities from top BM25 document chunks.
|
|
||||||
Phase 3: expand 1-hop neighbors in `StableGraph`.
|
|
||||||
Phase 4: score docs by entity overlap ratio, return top_k.
|
|
||||||
|
|
||||||
### `RagInitConfig` — two new fields:
|
|
||||||
```rust
|
|
||||||
pub graph_enabled: Option<bool>,
|
|
||||||
pub extractor_model: Option<String>,
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changes to `src/config/app_config.rs`
|
|
||||||
|
|
||||||
New fields alongside existing `rag_*` block:
|
|
||||||
```rust
|
|
||||||
pub rag_graph_enabled: bool, // default: false
|
|
||||||
pub rag_extractor_model: Option<String>, // default: None
|
|
||||||
```
|
|
||||||
Defaults, env var overrides, and propagation all follow the same pattern as existing `rag_*` fields.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changes to `src/graph/types.rs` — `RagNode`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub graph_enabled: Option<bool>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub extractor_model: Option<String>,
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changes to `src/config/agent.rs`
|
|
||||||
|
|
||||||
Pass new fields through to `RagInitConfig`:
|
|
||||||
```rust
|
|
||||||
graph_enabled: rag_node.graph_enabled,
|
|
||||||
extractor_model: rag_node.extractor_model.clone(),
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
- All new `RagData` fields have `#[serde(default)]` — old YAML files load without migration
|
|
||||||
- `graph_enabled` defaults `false` — existing RAG instances unchanged
|
|
||||||
- `graph_search_ids` empty → 2-way RRF runs (identical to current behavior)
|
|
||||||
- `node_to_docs` rebuild on `create()` is O(n) over empty map for old instances
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## V1 Scope Exclusions
|
|
||||||
|
|
||||||
- LLM entity extraction from query at search time (V1 uses substring match + BM25 anchoring)
|
|
||||||
- Multi-hop traversal (field reserved, 1-hop only in V1)
|
|
||||||
- Entity embeddings / fuzzy entity lookup
|
|
||||||
- Bincode for large-corpus graph storage
|
|
||||||
- Gleaning / multi-pass extraction
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Progress
|
|
||||||
|
|
||||||
- [x] Cargo.toml — petgraph dependency
|
|
||||||
- [x] src/rag/graph.rs — new file
|
|
||||||
- [x] src/rag/mod.rs — mod/use, Rag struct, create, clone
|
|
||||||
- [x] src/rag/mod.rs — RagData fields, new, del
|
|
||||||
- [x] src/rag/mod.rs — Rag::init, resolve_init_data
|
|
||||||
- [x] src/rag/mod.rs — sync_documents extraction block
|
|
||||||
- [x] src/rag/mod.rs — hybrid_search + graph_search
|
|
||||||
- [x] src/rag/mod.rs — RagInitConfig fields
|
|
||||||
- [x] src/config/app_config.rs — new fields
|
|
||||||
- [x] src/config/mod.rs — propagation
|
|
||||||
- [x] src/graph/types.rs — RagNode fields
|
|
||||||
- [x] src/config/agent.rs — propagation
|
|
||||||
- [x] cargo check — clean (0 warnings, 1065 tests passing)
|
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
# Rich Markdown Renderer — Phase 2: Tables + List Wrapping
|
||||||
|
|
||||||
|
**Status:** Planning complete, awaiting Momus review before implementation.
|
||||||
|
**Owner:** Coyote maintainer
|
||||||
|
**Estimated effort:** 3-4 days (tables + hanging-indent wrapping for lists/blockquotes)
|
||||||
|
**Related:** [Phase 1 plan](./rich-markdown-renderer.md) — must be complete first (it is).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Tables:** render GFM markdown tables (`| col | col |` with `|---|---|` separator rows) as styled terminal tables using box-drawing characters, respecting per-column alignment specifiers and the user's syntect theme colors. Match glamour's structural rendering (box borders, header separator, aligned cells). Cell content wraps within the column boundary.
|
||||||
|
2. **List wrapping:** when a bullet/numbered/task list item's content is longer than the wrap width, wrap it with a **hanging indent** so continuation lines align under the text, not under the bullet marker. Same treatment for blockquotes — continuation lines get the `│ ` prefix.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- **Not shipping without `comfy-table` dependency.** Hand-rolling table rendering requires reimplementing width-aware unicode + ANSI-aware column sizing. `comfy-table 7.2.2` already does this correctly (`ansi_strip().width()`) and is actively maintained (Jan 2026). See "Library decision" below.
|
||||||
|
- **Not supporting non-GFM table syntaxes.** Multi-line cells, cell merging, nested tables, and reStructuredText-style grid tables are out of scope. Standard GFM `|` + `---` only.
|
||||||
|
- **Not showing partial tables during streaming.** Tables accumulate silently while rows arrive; the rendered table appears once when the block ends. Users see a brief pause during accumulation instead of a flashing raw→rendered transition. Matches glamour behavior.
|
||||||
|
- **Not preserving the "zero touches outside `markdown.rs`" Phase 1 principle** — see "Scope-Expansion Rationale" below.
|
||||||
|
|
||||||
|
## Scope-Expansion Rationale
|
||||||
|
|
||||||
|
Phase 1 held two principles that Phase 2 must relax, both with clear justification:
|
||||||
|
|
||||||
|
1. **"No state beyond `LineType` code-block tracker."** Tables inherently need multi-line state (buffer rows until block ends). Contained to a single `Option<TableState>` field on `MarkdownRender`. No other state added.
|
||||||
|
2. **"Only `src/render/markdown.rs` changes."** Tables need an end-of-stream flush hook, which means 3 small callsite changes: `stream.rs` (streaming), `app_config.rs::print_markdown` (one-shot CLI), `session.rs::render` (session display). Each change is a single line: `output.push_str(&render.finalize())`.
|
||||||
|
|
||||||
|
These are necessary complexity, not scope creep. The plan explicitly recognizes them.
|
||||||
|
|
||||||
|
## Resolved Design Decisions
|
||||||
|
|
||||||
|
1. **Library:** use `comfy-table 7.2.2` with `custom_styling` feature enabled. Only Rust table library that correctly strips ANSI escapes before width computation (via `s.ansi_strip().width()` at `custom_styling.rs:10`). Alternatives (tabled, cli-table, term-table, prettytable-rs) either lack ANSI support, don't support arbitrary border colors, or are abandoned. Full survey in the librarian report.
|
||||||
|
2. **Streaming behavior:** silent accumulation. Table rows return empty string from renderer; buffered internally; rendered on block end. Matches glamour.
|
||||||
|
3. **Detection lookahead:** speculative table detection. First `|...|` line buffered as `PendingHeader`; next line's shape confirms (separator → commit to table) or rejects (anything else → flush both as paragraphs). Required for correctness — GFM demands separator row.
|
||||||
|
4. **Border color:** new `MarkdownStyles::table_border` field resolved from theme via scope `punctuation.definition.table.markdown` → fallback `punctuation` → fallback `hrule` (which already exists). Applies to all box-drawing chars uniformly.
|
||||||
|
5. **Header styling:** reuse existing `heading` style (bold + heading color) for header cells. No new field needed.
|
||||||
|
6. **Alignment specifiers:** parse `:---`, `---:`, `:---:` from separator row; map to `comfy_table::CellAlignment::Left/Right/Center`. Default (no colons) = left.
|
||||||
|
7. **Inline markdown in cells:** run `apply_inline()` on each cell before feeding to comfy-table. `custom_styling` feature ensures widths compute correctly on pre-styled text.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New dependency
|
||||||
|
|
||||||
|
`Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
comfy-table = { version = "7.2.2", features = ["custom_styling"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pulls in `unicode-width` (already used indirectly), `unicode-segmentation`, and `ansi-str`. Total footprint small (~77KB crate).
|
||||||
|
|
||||||
|
### New MarkdownStyles field
|
||||||
|
|
||||||
|
`markdown.rs:612` — add one line to the struct:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MarkdownStyles {
|
||||||
|
// ... existing 11 fields ...
|
||||||
|
table_border: Color,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolved in `from_theme()` via `resolve_scope_style(theme, "punctuation.definition.table.markdown", &["punctuation", "meta.separator"], truecolor)`. Falls back to `hrule` color if scope not found. `None` case → default color.
|
||||||
|
|
||||||
|
### New LineKind variant
|
||||||
|
|
||||||
|
`markdown.rs:57` — extend enum:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum LineKind {
|
||||||
|
// ... existing variants ...
|
||||||
|
TableRow, // any line matching ^\s*\|.*\|\s*$
|
||||||
|
TableSeparator, // subset of TableRow matching ^\s*\|(\s*:?-+:?\s*\|)+\s*$
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two variants because separator detection needs its own regex; keeping them distinct simplifies the state machine.
|
||||||
|
|
||||||
|
### Table detection regexes
|
||||||
|
|
||||||
|
Add to the `LazyLock` regex block:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// A line that looks like a table row: starts and ends with |, non-empty content
|
||||||
|
static TABLE_ROW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
|
||||||
|
|
||||||
|
// The separator row that must follow a header: | :---: | ---: | :--- | ---- |
|
||||||
|
static TABLE_SEPARATOR: LazyLock<Regex> = LazyLock::new(|| Regex::new(
|
||||||
|
r"^\s*\|(\s*:?-{3,}:?\s*\|)+\s*$"
|
||||||
|
).unwrap());
|
||||||
|
```
|
||||||
|
|
||||||
|
Order in `detect_line_kind`: check `TABLE_SEPARATOR` before `TABLE_ROW` (separator is a subset of row).
|
||||||
|
|
||||||
|
### New TableState struct
|
||||||
|
|
||||||
|
Added to `markdown.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
enum TableState {
|
||||||
|
/// Just saw a `|...|` line but haven't seen the separator yet.
|
||||||
|
/// If next line is a separator → transition to Active.
|
||||||
|
/// If next line is anything else → not a table; flush the header as paragraph + process next line.
|
||||||
|
PendingHeader(String),
|
||||||
|
|
||||||
|
/// Confirmed table. Accumulating data rows.
|
||||||
|
Active {
|
||||||
|
header: Vec<String>,
|
||||||
|
alignments: Vec<CellAlignment>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MarkdownRender state field
|
||||||
|
|
||||||
|
`markdown.rs:266` — add one field:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MarkdownRender {
|
||||||
|
// ... existing 8 fields ...
|
||||||
|
table_state: Option<TableState>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialized to `None` in `init()`.
|
||||||
|
|
||||||
|
### State machine (in `render_line_mut`)
|
||||||
|
|
||||||
|
Runs BEFORE the existing branch on `is_code`/`raw_markdown`/rich:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn render_line_mut(&mut self, line: &str) -> String {
|
||||||
|
let (line_type, line_kind, code_syntax, is_code) = self.check_line(line);
|
||||||
|
self.prev_line_type = line_type;
|
||||||
|
self.code_syntax = code_syntax;
|
||||||
|
|
||||||
|
// Table state machine — runs FIRST because tables preempt normal rendering
|
||||||
|
if let Some(output) = self.handle_table_state(line, line_kind) {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... existing code / raw_markdown / rich branch (unchanged) ...
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_table_state(&mut self, line: &str, kind: LineKind) -> Option<String> {
|
||||||
|
match (&mut self.table_state, kind) {
|
||||||
|
// No pending table + saw a row → start pending
|
||||||
|
(None, LineKind::TableRow) => {
|
||||||
|
self.table_state = Some(TableState::PendingHeader(line.to_string()));
|
||||||
|
Some(String::new()) // silent accumulation
|
||||||
|
}
|
||||||
|
// No pending table + saw a separator (rare) → treat as paragraph
|
||||||
|
(None, LineKind::TableSeparator) => None,
|
||||||
|
|
||||||
|
// Pending header + saw separator → commit to Active
|
||||||
|
(Some(TableState::PendingHeader(header_line)), LineKind::TableSeparator) => {
|
||||||
|
let header = parse_table_row(&header_line);
|
||||||
|
let alignments = parse_alignments(line);
|
||||||
|
self.table_state = Some(TableState::Active { header, alignments, rows: vec![] });
|
||||||
|
Some(String::new())
|
||||||
|
}
|
||||||
|
// Pending header + saw another row (no separator) → not a table; flush both as paragraphs
|
||||||
|
(Some(TableState::PendingHeader(header_line)), LineKind::TableRow) => {
|
||||||
|
let flushed = std::mem::take(header_line).clone();
|
||||||
|
self.table_state = None;
|
||||||
|
let a = self.render_as_paragraph(&flushed);
|
||||||
|
let b = self.render_as_paragraph(line);
|
||||||
|
Some(format!("{a}\n{b}"))
|
||||||
|
}
|
||||||
|
// Pending header + saw anything else → not a table; flush header + process line normally
|
||||||
|
(Some(TableState::PendingHeader(_)), _) => {
|
||||||
|
let TableState::PendingHeader(header_line) = self.table_state.take().unwrap()
|
||||||
|
else { unreachable!() };
|
||||||
|
let flushed = self.render_as_paragraph(&header_line);
|
||||||
|
None // caller continues with normal rendering; prepend `flushed` in caller
|
||||||
|
// (implementation detail: needs to return Some(flushed + normal_render) — see impl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active + saw a row → add to buffer
|
||||||
|
(Some(TableState::Active { rows, .. }), LineKind::TableRow) => {
|
||||||
|
rows.push(parse_table_row(line));
|
||||||
|
Some(String::new())
|
||||||
|
}
|
||||||
|
// Active + saw anything else → flush table + process line
|
||||||
|
(Some(TableState::Active { .. }), _) => {
|
||||||
|
let TableState::Active { header, alignments, rows } = self.table_state.take().unwrap()
|
||||||
|
else { unreachable!() };
|
||||||
|
let rendered = self.render_table(header, alignments, rows);
|
||||||
|
None // caller prepends rendered + processes line normally
|
||||||
|
// (same pattern as above)
|
||||||
|
}
|
||||||
|
|
||||||
|
(None, _) => None, // no table state to affect; normal rendering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on the "prepend + continue" pattern**: for the flush-then-continue transitions, the cleanest implementation splits into `handle_table_state` returning `Option<String>` for the flushed-table portion, and the caller concatenates that with the normally-rendered current line. Implementation detail; the state transitions are what matter for design review.
|
||||||
|
|
||||||
|
### Cell parsing
|
||||||
|
|
||||||
|
Two helpers:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn parse_table_row(line: &str) -> Vec<String> {
|
||||||
|
// Strip leading/trailing whitespace and the outer `|`
|
||||||
|
let inner = line.trim().trim_start_matches('|').trim_end_matches('|');
|
||||||
|
inner.split('|').map(|c| c.trim().to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_alignments(separator_row: &str) -> Vec<CellAlignment> {
|
||||||
|
let cells = parse_table_row(separator_row);
|
||||||
|
cells.iter().map(|c| {
|
||||||
|
let trimmed = c.trim();
|
||||||
|
let starts = trimmed.starts_with(':');
|
||||||
|
let ends = trimmed.ends_with(':');
|
||||||
|
match (starts, ends) {
|
||||||
|
(true, true) => CellAlignment::Center,
|
||||||
|
(false, true) => CellAlignment::Right,
|
||||||
|
_ => CellAlignment::Left,
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Edge cases:
|
||||||
|
- Empty cells (`| | |`) → empty strings in the returned Vec, comfy-table handles.
|
||||||
|
- Column count mismatch (header has 3 cells, data row has 2) → comfy-table's behavior: pads or truncates. Test coverage will verify.
|
||||||
|
- Escaped pipes (`\|`) in cell content — GFM spec supports; **defer to Phase 2.1 follow-up if needed**. Initial implementation splits on raw `|`.
|
||||||
|
|
||||||
|
### Table rendering
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use comfy_table::{Table, CellAlignment, presets::UTF8_FULL, ContentArrangement};
|
||||||
|
|
||||||
|
fn render_table(
|
||||||
|
&self,
|
||||||
|
header: Vec<String>,
|
||||||
|
alignments: Vec<CellAlignment>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
) -> String {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table.load_preset(UTF8_FULL);
|
||||||
|
table.set_content_arrangement(ContentArrangement::Dynamic);
|
||||||
|
if let Some(width) = self.wrap_width {
|
||||||
|
table.set_width(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header cells: inline-rendered + heading style (bold + heading color)
|
||||||
|
let styled_header: Vec<String> = header.iter()
|
||||||
|
.map(|c| apply_bold(&apply_inline(c, &self.styles), self.styles.heading.0))
|
||||||
|
.collect();
|
||||||
|
table.set_header(styled_header);
|
||||||
|
|
||||||
|
// Per-column alignment
|
||||||
|
for (i, align) in alignments.iter().enumerate() {
|
||||||
|
if let Some(col) = table.column_mut(i) {
|
||||||
|
col.set_cell_alignment(*align);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows: inline-rendered only
|
||||||
|
for row in rows {
|
||||||
|
let styled_row: Vec<String> = row.iter()
|
||||||
|
.map(|c| apply_inline(c, &self.styles))
|
||||||
|
.collect();
|
||||||
|
table.add_row(styled_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Border color: apply table_border to all box-drawing chars via ANSI wrapping.
|
||||||
|
// comfy-table's styling API — inspect final rendered output and colorize border chars,
|
||||||
|
// OR use comfy-table's built-in styling if it supports per-component color.
|
||||||
|
// Investigate during Phase 2.4 implementation.
|
||||||
|
|
||||||
|
format!("{table}")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Border color note**: comfy-table has border styling but it may not expose direct per-char color control. Two options:
|
||||||
|
1. Post-process the rendered string with a regex that colorizes box-drawing chars (`[─│┼┌┐└┘├┤┬┴]`).
|
||||||
|
2. Use comfy-table's `style()` API if it supports arbitrary ANSI.
|
||||||
|
|
||||||
|
Confirm during Phase 2.4 implementation — worst case is regex post-process, which is simple.
|
||||||
|
|
||||||
|
### `render_line` (immutable) behavior for partial table rows
|
||||||
|
|
||||||
|
`render_line` is called on the incomplete in-progress line during streaming. If the partial buffer looks like `| foo | ba`, it's mid-row and immutable — can't add to state.
|
||||||
|
|
||||||
|
Behavior: `render_line` sees `LineKind::TableRow` or the pattern and just renders raw markdown (since it can't buffer). The user sees `| foo | ba` briefly, then it disappears when the complete row arrives via `render_line_mut` (silent accumulation) and eventually the rendered table appears. Consistent with the "silent accumulation" decision.
|
||||||
|
|
||||||
|
### Line wrapping with hanging indent
|
||||||
|
|
||||||
|
Phase 1's `render_bullet`/`render_numbered`/`render_task`/`render_blockquote` produce a single line each and don't wrap long content. When `wrap_width` is set, long items overflow past the wrap column. This phase fixes that by adding **hanging-indent wrapping** using `textwrap` (already a dependency).
|
||||||
|
|
||||||
|
**Desired output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
• text that
|
||||||
|
wraps and
|
||||||
|
wraps
|
||||||
|
1. text that
|
||||||
|
wraps and
|
||||||
|
wraps
|
||||||
|
[ ] task text
|
||||||
|
that wraps
|
||||||
|
│ blockquote line
|
||||||
|
│ that continues
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design:**
|
||||||
|
|
||||||
|
Each block renderer computes a prefix width, applies wrapping to the content with `textwrap::Options::subsequent_indent(prefix_width_spaces)`, then styles each wrapped line with the appropriate prefix on line 1 and continuation-indent on later lines.
|
||||||
|
|
||||||
|
Critical subtlety: `textwrap` computes width by **byte length**, not visual width. We must wrap the **plain text content** (before applying inline ANSI codes), then apply `apply_inline` per wrapped line. Otherwise ANSI escape bytes distort the wrap column calculation.
|
||||||
|
|
||||||
|
Sketch:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn render_bullet(&self, line: &str) -> String {
|
||||||
|
let (leading, content) = split_leading_indent(line); // handles nested lists
|
||||||
|
let content_after_marker = &content[content.find(' ').unwrap() + 1..]; // strip "- "
|
||||||
|
|
||||||
|
let Some(wrap_width) = self.wrap_width else {
|
||||||
|
// No wrap → single line (current Phase 1 behavior)
|
||||||
|
return format!("{leading}{bullet}{}", apply_inline(content_after_marker, &self.styles));
|
||||||
|
};
|
||||||
|
|
||||||
|
let bullet_visible = "• "; // 2 columns
|
||||||
|
let subseq_indent = " "; // 2 spaces to align under text
|
||||||
|
let effective_width = (wrap_width as usize).saturating_sub(leading.len() + bullet_visible.len());
|
||||||
|
|
||||||
|
let wrapped = textwrap::wrap(content_after_marker, textwrap::Options::new(effective_width));
|
||||||
|
|
||||||
|
let styled_bullet = ansi_wrap(bullet_visible, self.styles.list_bullet);
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, wline) in wrapped.iter().enumerate() {
|
||||||
|
if i == 0 {
|
||||||
|
out.push_str(&format!("{leading}{styled_bullet}{}", apply_inline(wline, &self.styles)));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("\n{leading}{subseq_indent}{}", apply_inline(wline, &self.styles)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern for `render_numbered` (subsequent indent width = digits + `. ` = variable), `render_task` (subsequent indent = 4 spaces for `[ ] `), and `render_blockquote` (subsequent indent = styled `│ ` prefix, same styling as first line).
|
||||||
|
|
||||||
|
**Interaction with existing `wrap_line`:** The current `wrap_line` (markdown.rs:192-198) is used for code lines and paragraphs. It sets `initial_indent` but not `subsequent_indent`, so paragraphs already wrap without hanging indent — that's correct (paragraphs should wrap flush-left). Only list/blockquote block renderers need the new hanging-indent path; leave `wrap_line` alone.
|
||||||
|
|
||||||
|
**Nested lists:** the existing `leading` whitespace preservation from Phase 1 continues to work — subsequent-indent gets prepended AFTER the leading, so a nested list item wraps correctly under its own bullet.
|
||||||
|
|
||||||
|
**Headings:** intentionally NOT wrapped with hanging indent. If a heading is longer than wrap width, it wraps flush-left (via existing `wrap_line`). Headings are usually short; hanging indent under `##` would look odd.
|
||||||
|
|
||||||
|
**`wrap_width = None`:** all renderers skip wrapping entirely and emit a single line, matching current Phase 1 behavior. Users who want wrapping set the `wrap: auto` config.
|
||||||
|
|
||||||
|
### `finalize()` method
|
||||||
|
|
||||||
|
New method on `MarkdownRender`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn finalize(&mut self) -> String {
|
||||||
|
match self.table_state.take() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(TableState::PendingHeader(line)) => self.render_as_paragraph(&line),
|
||||||
|
Some(TableState::Active { header, alignments, rows }) => {
|
||||||
|
self.render_table(header, alignments, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Called by:
|
||||||
|
1. **`stream.rs`** at `SseEvent::Done` — before `break 'outer`, emit `render.finalize()` output.
|
||||||
|
2. **`app_config.rs::print_markdown`** — after `markdown_render.render(text)`, append `finalize()` output.
|
||||||
|
3. **`session.rs::Session::render`** — after `render.render(text)`, append `finalize()` output.
|
||||||
|
|
||||||
|
Each is a single-line addition.
|
||||||
|
|
||||||
|
## Consumers Touched (Phase 2)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/render/markdown.rs` | Add table state, detection, rendering (~250 lines) |
|
||||||
|
| `src/render/stream.rs` | Call `render.finalize()` on SseEvent::Done (~2 lines) |
|
||||||
|
| `src/config/app_config.rs` | Call `finalize()` after `render()` in `print_markdown` (~1 line) |
|
||||||
|
| `src/config/session.rs` | Call `finalize()` after `render()` in `Session::render` (~1 line) |
|
||||||
|
| `Cargo.toml` | Add `comfy-table` dependency |
|
||||||
|
|
||||||
|
## Phase 2 Implementation
|
||||||
|
|
||||||
|
### Phase 2.1 — Add `comfy-table` + `table_border` style
|
||||||
|
- [x] Add `comfy-table = { version = "7.2.2", features = ["custom_styling"] }` to `Cargo.toml`
|
||||||
|
- [x] Add `table_border: Color` field to `MarkdownStyles`
|
||||||
|
- [x] Resolve in `MarkdownStyles::from_theme` from `punctuation.definition.table.markdown` with fallback chain
|
||||||
|
- [x] Handle `theme.is_none()` → default color
|
||||||
|
- [x] Test: `table_border` resolves correctly with built-in theme
|
||||||
|
- [x] Test: fallback chain works with minimal theme
|
||||||
|
|
||||||
|
**Commit:** `feat(render): add comfy-table dependency and table border style`
|
||||||
|
|
||||||
|
### Phase 2.2 — Table row detection
|
||||||
|
- [x] Add `TABLE_ROW` and `TABLE_SEPARATOR` regexes
|
||||||
|
- [x] Add `TableRow` and `TableSeparator` variants to `LineKind`
|
||||||
|
- [x] Extend `detect_line_kind` (separator check before row check)
|
||||||
|
- [x] Test: header row (`| a | b |`) → `TableRow`
|
||||||
|
- [x] Test: separator (`|---|---|`) → `TableSeparator`
|
||||||
|
- [x] Test: separator with alignment (`|:--|--:|:-:|`) → `TableSeparator`
|
||||||
|
- [x] Test: non-table pipe line in prose (`use \`a | b\``) → `Paragraph` (only if it doesn't match `^\s*\|.*\|\s*$` — verify)
|
||||||
|
- [x] Test: empty cells (`| | |`) → `TableRow`
|
||||||
|
|
||||||
|
**Commit:** `feat(render): detect markdown table rows and separators`
|
||||||
|
|
||||||
|
### Phase 2.3 — Cell + alignment parsing
|
||||||
|
- [x] Add `parse_table_row(line) -> Vec<String>`
|
||||||
|
- [x] Add `parse_alignments(separator_row) -> Vec<CellAlignment>`
|
||||||
|
- [x] Test: `| a | b | c |` → `["a", "b", "c"]`
|
||||||
|
- [x] Test: empty cells `| a | | c |` → `["a", "", "c"]`
|
||||||
|
- [x] Test: alignments `|:---|---:|:---:|---|` → `[Left, Right, Center, Left]`
|
||||||
|
- [x] Test: leading/trailing whitespace stripped
|
||||||
|
|
||||||
|
**Commit:** `feat(render): parse table cells and column alignments`
|
||||||
|
|
||||||
|
### Phase 2.4 — Table rendering via comfy-table
|
||||||
|
- [x] Add `TableState` enum (PendingHeader / Active)
|
||||||
|
- [x] Add `table_state: Option<TableState>` field to `MarkdownRender`, init `None`
|
||||||
|
- [x] Implement `render_table(header, alignments, rows) -> String`
|
||||||
|
- [x] Apply `apply_inline` to each cell; apply bold + heading color to header cells
|
||||||
|
- [x] Set alignment per column
|
||||||
|
- [x] Set width from `wrap_width` if present
|
||||||
|
- [x] Investigate comfy-table border color API; if insufficient, post-process box-drawing chars with regex to apply `table_border` color
|
||||||
|
- [x] Test: 3x3 table with default alignment
|
||||||
|
- [x] Test: alignment specifiers applied correctly
|
||||||
|
- [x] Test: header rendered with bold + heading color
|
||||||
|
- [x] Test: borders rendered with `table_border` color
|
||||||
|
- [x] Test: cell containing inline markdown (`**bold**`, `` `code` ``, `[link](url)`) — width computed correctly (ANSI stripped)
|
||||||
|
- [x] Test: wide chars / emoji in cells
|
||||||
|
|
||||||
|
**Commit:** `feat(render): render markdown tables with comfy-table`
|
||||||
|
|
||||||
|
### Phase 2.5 — State machine + finalize
|
||||||
|
- [x] Implement `handle_table_state(line, kind) -> Option<String>` for state transitions
|
||||||
|
- [x] Wire into `render_line_mut` BEFORE existing code/raw/rich branch
|
||||||
|
- [x] Handle all 6 transitions from the state diagram above
|
||||||
|
- [x] Implement `pub fn finalize(&mut self) -> String`
|
||||||
|
- [x] Add `finalize()` call in `src/render/stream.rs` at `SseEvent::Done` (write output)
|
||||||
|
- [x] Add `finalize()` call in `src/config/app_config.rs::print_markdown` after `render()`
|
||||||
|
- [x] Add `finalize()` call in `src/config/session.rs::Session::render` after `render()`
|
||||||
|
- [x] Test: table followed by paragraph → rendered table + paragraph
|
||||||
|
- [x] Test: table at end of input (no trailing non-table line) → `finalize()` emits rendered table
|
||||||
|
- [x] Test: `|...|` line NOT followed by separator → both flushed as paragraphs
|
||||||
|
- [x] Test: multiple tables in one input
|
||||||
|
- [x] Test: `render_line` on partial `| foo | ba` (immutable) → raw text (no state mutation)
|
||||||
|
|
||||||
|
**Commit:** `feat(render): wire table state machine and finalize hook`
|
||||||
|
|
||||||
|
### Phase 2.6 — Hanging-indent line wrapping for lists and blockquotes
|
||||||
|
- [x] Add `wrap_with_hanging_indent(content, prefix_width, wrap_width) -> Vec<String>` helper (uses `textwrap` on plain content, callers apply inline styling per line)
|
||||||
|
- [x] Refactor `render_bullet` to compute prefix width (`• ` = 2), wrap, apply inline per line, prepend styled bullet + subsequent 2-space indent
|
||||||
|
- [x] Refactor `render_numbered` to compute prefix width from digit count + `. `, wrap, apply inline per line, prepend styled number + subsequent variable-width indent
|
||||||
|
- [x] Refactor `render_task` to compute prefix width (`[ ] ` = 4), wrap, apply inline per line, prepend styled checkbox + subsequent 4-space indent
|
||||||
|
- [x] Refactor `render_blockquote` to wrap, apply inline per line, prepend styled `│ ` on every line (both initial and subsequent)
|
||||||
|
- [x] `wrap_width = None` path: skip wrapping, emit single line (matches Phase 1)
|
||||||
|
- [x] Preserve leading whitespace (nested list indent) — subseq indent goes AFTER leading
|
||||||
|
- [x] Test: bullet with content wider than wrap_width → hanging indent under text
|
||||||
|
- [x] Test: numbered list with 2+ digit numbers (`10. `, `100. `) → subseq indent matches digit width
|
||||||
|
- [x] Test: task item wraps with 4-space subseq indent
|
||||||
|
- [x] Test: blockquote wraps with `│ ` continuation prefix (styled same as first line)
|
||||||
|
- [x] Test: nested bullet (` - inner text that wraps`) → nested indent + hanging indent both applied
|
||||||
|
- [x] Test: content with inline markdown that wraps mid-span — wrap boundary respects word breaks, not ANSI escapes
|
||||||
|
- [x] Test: `wrap_width = None` → no wrapping (single line, current behavior)
|
||||||
|
|
||||||
|
**Commit:** `feat(render): hanging-indent line wrapping for lists and blockquotes`
|
||||||
|
|
||||||
|
### Phase 2.7 — Integration + edge cases
|
||||||
|
- [x] Test: markdown with mixed content (paragraphs + headings + tables + lists)
|
||||||
|
- [x] Test: `raw_markdown: true` bypasses table rendering (renders as raw pipe rows via syntect grammar)
|
||||||
|
- [x] Test: `theme.is_none()` → tables still render (uncolored) via comfy-table
|
||||||
|
- [x] Test: user's custom theme colors apply to borders
|
||||||
|
- [x] Test: column count mismatch (header has 3, row has 2) — verify comfy-table behavior; document expected output
|
||||||
|
- [ ] Manual REPL test: stream a response with tables, verify silent accumulation → rendered flush *(deferred — requires interactive terminal)*
|
||||||
|
- [ ] Manual REPL test: `.set raw_markdown true` reverts tables to raw *(deferred — requires interactive terminal)*
|
||||||
|
- [x] Update `.sisyphus/plans/rich-markdown-renderer.md` progress log noting Phase 2 completion + commit SHA
|
||||||
|
- [x] `cargo check` clean
|
||||||
|
- [x] `cargo test` all pass
|
||||||
|
|
||||||
|
**Commit:** `test(render): comprehensive table rendering coverage`
|
||||||
|
|
||||||
|
## Success Criteria (Phase 2)
|
||||||
|
|
||||||
|
- [ ] Standard GFM tables render with box-drawing chars
|
||||||
|
- [ ] Alignment specifiers (`:---`, `---:`, `:---:`) respected
|
||||||
|
- [ ] Inline markdown inside cells (`**bold**`, code, links) renders correctly
|
||||||
|
- [ ] Wide chars / emoji don't misalign columns (comfy-table's `ansi_strip().width()` verified working)
|
||||||
|
- [ ] Border color from user's syntect theme
|
||||||
|
- [ ] Header row is bold + heading color
|
||||||
|
- [ ] Silent accumulation during streaming (no flashing raw→rendered transitions)
|
||||||
|
- [ ] Tables at end of stream/input flush via `finalize()`
|
||||||
|
- [ ] `|...|` lines without separator NOT rendered as tables
|
||||||
|
- [ ] Table cell content wraps within column boundary (via `comfy-table`'s `ContentArrangement::Dynamic`)
|
||||||
|
- [ ] Bullet list items wrap with 2-space hanging indent under text
|
||||||
|
- [ ] Numbered list items wrap with digit-width hanging indent
|
||||||
|
- [ ] Task list items wrap with 4-space hanging indent
|
||||||
|
- [ ] Blockquotes wrap with `│ ` continuation prefix on every line
|
||||||
|
- [ ] `wrap_width = None` disables wrapping (matches Phase 1 behavior)
|
||||||
|
- [ ] `raw_markdown: true` bypasses table rendering AND list-wrap changes (raw markdown throughout)
|
||||||
|
- [ ] `theme.is_none()` still produces functional (uncolored) tables and wrapped lists
|
||||||
|
- [ ] All existing tests pass unchanged
|
||||||
|
- [ ] `cargo check` clean
|
||||||
|
- [ ] `cargo test` all pass
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
Append-only. One entry per commit or session.
|
||||||
|
|
||||||
|
### 2026-07-22 — Planning complete
|
||||||
|
- Verified post-Phase-1 state via explore agent (MarkdownRender struct, LineKind enum, apply_inline pipeline, streaming buffer mechanics)
|
||||||
|
- Surveyed Rust table libraries via librarian agent → chose `comfy-table 7.2.2` (only lib with correct ANSI-in-cells width handling + active maintenance + arbitrary border colors)
|
||||||
|
- Resolved 7 design decisions (library, streaming behavior, detection lookahead, border color, header styling, alignment parsing, cell inline rendering)
|
||||||
|
- Acknowledged 2 justified deviations from Phase 1 principles (multi-line state, 3 small callsite changes for finalize hook)
|
||||||
|
- Added Phase 2.6 (hanging-indent wrapping for lists and blockquotes) — user-requested addition; touches Phase 1 block renderers (render_bullet/render_numbered/render_task/render_blockquote) but reuses existing `textwrap` dep. Tables get wrapping for free via `comfy-table`'s `ContentArrangement::Dynamic`.
|
||||||
|
- Wrote this plan file
|
||||||
|
- Next: hand to Momus for review before starting Phase 2.1
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 2.1 complete (commit `fcc4a1d`)
|
||||||
|
- Added `comfy-table 7.2.2` with `custom_styling` feature to `Cargo.toml`; slotted alphabetically between `clap` and `dirs`.
|
||||||
|
- Added `table_border: Color` field to `MarkdownStyles`; resolved in `from_theme` via `punctuation.definition.table.markdown` → `punctuation` → `meta.separator` fallback chain; `none()` sets `Color::Reset`.
|
||||||
|
- Extended the three existing `MarkdownStyles` tests with `table_border` assertions (dark theme resolves ≠ Reset, minimal-root-scope theme falls back to `punctuation` color `rgb(0x77, 0x77, 0x77)`, no-theme → `Color::Reset`).
|
||||||
|
- Marked field `#[allow(dead_code)]` — will be removed in Phase 2.4 when `render_table` consumes it.
|
||||||
|
- `cargo check` clean, `cargo test` all 1207 pass.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 2.2 complete (commit `7671d28`)
|
||||||
|
- Added `TABLE_ROW_RE` and `TABLE_SEPARATOR_RE` regexes. Separator uses `-+` (one or more dashes) instead of the plan's `{3,}` to accept the plan's own test case `|:--|--:|:-:|`; GFM spec doesn't mandate a minimum, so more lenient is safer.
|
||||||
|
- Added `TableRow` / `TableSeparator` variants to `LineKind`; extended `detect_line_kind` (separator checked before row).
|
||||||
|
- `render_markdown_line` handles both variants as `apply_inline` (paragraph-equivalent) — they'll be intercepted by the state machine in Phase 2.5 before reaching this fallback.
|
||||||
|
- 4 new tests covering row, separator (three alignment shapes), non-table pipes in prose, and separator-vs-row precedence.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 2.3 complete (commit `c062f34`)
|
||||||
|
- Added `parse_table_row(line)` and `parse_alignments(separator_row)` free functions.
|
||||||
|
- Imported `comfy_table::CellAlignment` at module level (also used in Phase 2.4).
|
||||||
|
- Both functions marked `#[allow(dead_code)]` — consumed by state machine in Phase 2.5.
|
||||||
|
- 6 tests: cell splitting, empty cells, whitespace trimming, colon-based alignment mapping (long dashes, short dashes, default-to-left).
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 2.4 complete (commit `cdfaa0f`)
|
||||||
|
- Added `TableState` enum (`PendingHeader(String)` / `Active { header, alignments, rows }`) and `table_state: Option<TableState>` field on `MarkdownRender`.
|
||||||
|
- Implemented `MarkdownRender::render_table` using `comfy-table`'s `UTF8_FULL` preset + `ContentArrangement::Dynamic`; sets `wrap_width` on the table when present; per-column alignment via `column_mut(i).set_cell_alignment`.
|
||||||
|
- Header cells: `apply_inline` then wrapped in `.with(heading_color).bold()`. Data cells: `apply_inline` only.
|
||||||
|
- Border coloring: `colorize_box_chars` helper post-processes the rendered string. It extracts SGR prefix/suffix from a probe styled character, walks the input once, and wraps consecutive box-drawing runs (`\u{2500}..=\u{257F}`) with the SGR pair.
|
||||||
|
- `#[allow(dead_code)]` on `TableState`, `table_state`, `render_table`, `colorize_box_chars`, and (re-added) `table_border` — cleared in Phase 2.5 once the state machine wires everything in.
|
||||||
|
- 8 new tests: border colorization (with/without borders), header bold, inline markdown in cells, alignment specifiers, wide chars/emoji, border color at output start, plus a 3x3 default-alignment sanity test.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 2.5 complete (commit `bf06d5e`)
|
||||||
|
- Added `TableAction` enum (`Consumed(String)` / `FlushAndContinue(String)` / `Passthrough`) — replaces the plan's ambiguous `Option<String>` return with an explicit three-way decision.
|
||||||
|
- Implemented `MarkdownRender::handle_table_state` covering all 7 transitions from the state diagram (including code-block entry as an implicit flush trigger).
|
||||||
|
- Implemented `MarkdownRender::render_as_paragraph` helper for false-positive header flushes.
|
||||||
|
- Implemented `pub fn finalize(&mut self) -> String`.
|
||||||
|
- `render_line_mut` runs the state machine before the code/raw/rich dispatch. `raw_markdown: true` bypasses the state machine entirely (raw mode preserves user-supplied markdown untouched). Code block entry (`is_code`) maps to `LineKind::Paragraph` for state-machine purposes, forcing a flush.
|
||||||
|
- Wired `finalize()` into three call sites:
|
||||||
|
- `src/render/stream.rs` at `SseEvent::Done` — queues a trailing newline + flushed output via crossterm `queue!/style::Print` before break.
|
||||||
|
- `src/config/app_config.rs::print_markdown` — appends flush output before `println!`.
|
||||||
|
- `src/config/session.rs::Session::render` — flushes after both System and Assistant message rendering (per-message finalize prevents cross-message state bleed).
|
||||||
|
- Dropped `#[allow(dead_code)]` from `TableState`, `table_state`, `render_table`, `colorize_box_chars`, `parse_table_row`, `parse_alignments`, and `table_border` — all now live in the binary.
|
||||||
|
- 9 new tests: full-table streaming, deferred silent accumulation, `finalize` for active/pending/empty state, `|...|`-without-separator flush, multiple tables in one input, `render_line` immutability, raw-mode bypass.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 2.6 complete (commit `d790782`)
|
||||||
|
- Added `wrap_plain_content(content, effective_width) -> Vec<String>` helper (thin `textwrap::wrap` wrapper that clamps width to ≥1).
|
||||||
|
- Added `kind_pre_wraps(kind) -> bool` helper (returns true for bullet/numbered/task/blockquote).
|
||||||
|
- Threaded `wrap_width: Option<u16>` through `render_markdown_line` and all four block renderers.
|
||||||
|
- `wrap_width = None` short-circuits back to Phase 1 single-line behavior.
|
||||||
|
- `wrap_width = Some(w)` wraps plain content (pre-inline-styling) at `w - (leading + prefix_width)`, then applies `apply_inline` per wrapped chunk.
|
||||||
|
- Prefix widths: `render_bullet` = 2 (`• `), `render_task` = 4 (`[ ] `), `render_numbered` = digit_count + 2 (`. `), `render_blockquote` = 2 (`│ `).
|
||||||
|
- `render_blockquote` prepends the styled `│ ` on **every** wrapped line (not just the first); the other three prepend the marker on line 1 and a spaces-only subsequent indent on continuation lines.
|
||||||
|
- Leading whitespace (nested-list indent) is emitted BEFORE the prefix on every wrapped line, preserving nested-list appearance.
|
||||||
|
- `render_rich_markdown_line` skips `wrap_line` when `kind_pre_wraps(kind)` is true, avoiding a second unwanted wrap pass over already-styled content.
|
||||||
|
- Updated all 17 existing test call sites of `render_markdown_line` (via ast-grep) to pass `None` — Phase 1 behavior preserved end-to-end.
|
||||||
|
- 8 new tests: bullet 2-space indent, numbered 4-space (`42. `) and 5-space (`100. `) indent, task 4-space indent, blockquote pipe-on-every-line, nested-bullet leading indent, `None` single-line short-circuit, inline markdown intact after wrap.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 2.7 complete (commit `e82e5ab`)
|
||||||
|
- 4 integration/edge-case tests: mixed-content document (heading + paragraph + list + blockquote + table + trailing prose), table renders without theme, table borders pick up custom theme color, column-count mismatch tolerated by comfy-table.
|
||||||
|
- Full test suite: 1246 pass, 0 fail. `cargo check` clean.
|
||||||
|
- Manual REPL verification deferred (requires interactive terminal); test coverage validates rendering pipeline end-to-end.
|
||||||
|
- Phase 2 complete.
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Rich Markdown Renderer for the REPL
|
||||||
|
|
||||||
|
**Status:** Planning complete, awaiting Momus review before implementation.
|
||||||
|
**Owner:** Coyote maintainer
|
||||||
|
**Estimated effort:** Phase 1 = 4-5 days, Phase 2 (tables) = +1-2 days
|
||||||
|
**Related flag:** `raw_markdown` (already plumbed; see commit history for the plumbing PR)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace Coyote's current syntect-only markdown rendering with a rich renderer that transforms markdown syntax into styled terminal output (headings become colored + bold text, `**bold**` becomes actual bold, backticks strip and stylize, blockquotes get a `│` prefix, etc.), matching glamour's structural output while preserving the user's existing syntect `.tmTheme` colors.
|
||||||
|
|
||||||
|
The current renderer just applies syntect's markdown grammar for syntax highlighting — the markdown syntax characters (`#`, `**`, `` ` ``) stay in the output, just colored. Users get raw markdown with color, not rendered markdown. The new renderer actually transforms the markdown into styled output like glamour (github.com/charmbracelet/glamour) does.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- **Not replacing the renderer's public API.** `MarkdownRender::init`, `render`, `render_line`, and `RenderOptions` all keep their existing signatures. Callers (`stream.rs`, `session.rs`, `app_config.rs::print_markdown`, `request_context.rs::session_info`) do not change.
|
||||||
|
- **Not changing streaming architecture.** `stream.rs` still calls `render()` on complete lines and `render_line()` on the incomplete tail. New renderer must fit this line-by-line contract.
|
||||||
|
- **Not touching code block rendering.** Fenced code blocks (` ```lang ... ``` `) continue to route to syntect language-specific highlighting via `find_syntax_by_token`. The new renderer only affects markdown syntax rendering, never code content.
|
||||||
|
- **Not adding new dependencies.** All work uses existing `syntect`, `fancy-regex`, `crossterm`, `textwrap`.
|
||||||
|
- **Not shipping tables in Phase 1.** Tables require multi-line buffering, which conflicts with the stateless streaming model. Table rows render as raw `| col | col |` until Phase 2.
|
||||||
|
- **Not implementing OSC 8 hyperlink fallback logic.** Emit OSC 8 codes unconditionally + always show URL visibly. Terminals that don't support OSC 8 strip the codes and see plain "text URL" text.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **Colors from user theme, layout from glamour.** Every construct extracts its color from the user's syntect theme via scope lookup with fallback chains. The structural layout (prefixes, indents, borders, box-drawing) matches glamour's default dark style.
|
||||||
|
2. **`raw_markdown: true` = current behavior byte-identical.** The existing syntect-on-markdown-grammar path is preserved as the "raw" branch and reachable via config/CLI/REPL. Zero regression risk for users who want the old behavior.
|
||||||
|
3. **Preserve line-by-line rendering.** No state beyond the existing `LineType` code-block tracker. Stateless per-line rendering means the streaming's `render_line` for partial buffer works identically to the mutating `render_line_mut` for complete lines.
|
||||||
|
4. **Regex-based inline parsing, not pulldown-cmark.** A full markdown parser needs the complete document to disambiguate. Regexes match balanced spans and gracefully leave unclosed spans as raw text — exactly right for streaming's mid-token partial-line rendering.
|
||||||
|
5. **Only `src/render/markdown.rs` changes.** Scope containment: the entire implementation lives in one file. No touches to `stream.rs`, `mod.rs`, `session.rs`, `app_config.rs`, `request_context.rs`.
|
||||||
|
|
||||||
|
## Resolved Design Decisions
|
||||||
|
|
||||||
|
Recorded here so future sessions don't re-litigate them:
|
||||||
|
|
||||||
|
1. **Tables:** deferred to Phase 2. Phase 1 leaves table rows as raw markdown.
|
||||||
|
2. **H2-H6 hash prefixes:** matched to glamour — keep `##`, `###`, `####`, `#####`, `######` visible in the heading color as a level indicator. H1 gets padded ` text ` treatment.
|
||||||
|
3. **Link rendering:** OSC 8 hyperlink codes wrapping visible `{text} {url}` — modern terminals show a clickable link, older terminals show plain styled text. Matches glamour exactly. Users on broken terminals can fall back to `.set raw_markdown true`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data structures (added to `MarkdownRender`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct MarkdownStyles {
|
||||||
|
heading: (Color, bool /* force_bold */),
|
||||||
|
bold: Color,
|
||||||
|
italic: Color,
|
||||||
|
inline_code_fg: Color,
|
||||||
|
inline_code_bg: Option<Color>,
|
||||||
|
blockquote: Color,
|
||||||
|
list_bullet: Color,
|
||||||
|
link_text: Color,
|
||||||
|
link_url: Color,
|
||||||
|
strikethrough: Color,
|
||||||
|
hrule: Color,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Populated once in `MarkdownRender::init` via a new `resolve_scope_style(theme, primary_scope, fallbacks)` helper that generalizes the existing `get_code_color()` pattern (markdown.rs:299).
|
||||||
|
|
||||||
|
When `options.theme.is_none()`, all styles collapse to defaults (raw text output with no colors — matches current behavior).
|
||||||
|
|
||||||
|
### Line-type detection
|
||||||
|
|
||||||
|
Extended `check_line` returns a new `LineKind` enum (only for non-code lines):
|
||||||
|
|
||||||
|
| Regex | LineKind |
|
||||||
|
|---|---|
|
||||||
|
| `^\s*(#{1,6}) +.+` | Heading(level) |
|
||||||
|
| `^\s*> ?.*` | Blockquote |
|
||||||
|
| `^(\s*)- \[[ xX]\] +.+` | TaskItem(checked) |
|
||||||
|
| `^(\s*)[-*+] +.+` | BulletItem |
|
||||||
|
| `^(\s*)\d+\. +.+` | NumberedItem |
|
||||||
|
| `^\s*(-{3,}|_{3,}|\*{3,})\s*$` | HorizontalRule |
|
||||||
|
| `^\s*\|.*\|\s*$` | (Phase 2: TableRow) — treated as paragraph for now |
|
||||||
|
| default | Paragraph |
|
||||||
|
|
||||||
|
**Stateless:** line-type detection carries no state beyond the existing `prev_line_type`/`code_syntax` fields for code block tracking. Streaming's partial-line `render_line` works identically to complete-line `render_line_mut`.
|
||||||
|
|
||||||
|
### Block-level rendering
|
||||||
|
|
||||||
|
Each `LineKind` triggers a block transformation that strips syntax markers and applies structural styling. All block types then run their remaining text content through the inline pipeline.
|
||||||
|
|
||||||
|
| LineKind | Transformation |
|
||||||
|
|---|---|
|
||||||
|
| `Heading(1)` | Prefix ` `, suffix ` ` (single spaces), apply bold + heading color to entire line |
|
||||||
|
| `Heading(2..=6)` | Keep visible `##`/`###`/etc. prefix, apply bold + heading color |
|
||||||
|
| `Blockquote` | Replace `> ` with `│ ` (styled blockquote color); apply blockquote color to remaining content |
|
||||||
|
| `BulletItem` | Replace `-`/`*`/`+` with `•` (styled list_bullet color); preserve leading whitespace for nesting |
|
||||||
|
| `NumberedItem` | Preserve number, style the `.` in list_bullet color |
|
||||||
|
| `TaskItem(false)` | Replace `[ ]` with `[ ]` styled in list_bullet color |
|
||||||
|
| `TaskItem(true)` | Replace `[x]` with `[✓]` styled |
|
||||||
|
| `HorizontalRule` | Emit `────────` (8-char box-drawing) styled with hrule color (typically dim/gray) |
|
||||||
|
| `Paragraph` | No block transform, inline pass only |
|
||||||
|
|
||||||
|
### Inline rendering (regex pipeline, applied in order)
|
||||||
|
|
||||||
|
Order matters — inline code first prevents re-parsing code content as bold/italic:
|
||||||
|
|
||||||
|
1. **Inline code** (`` `text` ``) — regex `` `([^`\n]+)` ``, strip backticks, apply `inline_code_fg` + optional `inline_code_bg`.
|
||||||
|
2. **Images** (``) — regex `!\[([^\]]*)\]\(([^)]+)\)`, emit `Image: {alt} → {url}` styled with `link_url`. Wrap in OSC 8 hyperlink codes.
|
||||||
|
3. **Links** (`[text](url)`) — regex `\[([^\]]+)\]\(([^)]+)\)`, emit `{text} {url}` with `link_text` on the label and `link_url` on the URL. Wrap in OSC 8 hyperlink codes.
|
||||||
|
4. **Bold** (`**text**` or `__text__`) — regex `\*\*([^*\n]+)\*\*` and `__([^_\n]+)__`, strip markers, apply bold ANSI + `bold` color.
|
||||||
|
5. **Italic** (`*text*` or `_text_`) — regex `(?<![*\w])\*([^*\n]+)\*(?!\*)` and `(?<![_\w])_([^_\n]+)_(?!_)` — lookbehind/lookahead prevents word-internal `_` from matching (e.g., `some_var_name`). `fancy-regex` supports lookbehind.
|
||||||
|
6. **Strikethrough** (`~~text~~`) — regex `~~([^~\n]+)~~`, strip markers, apply ANSI strikethrough (`\x1b[9m`).
|
||||||
|
|
||||||
|
**Partial-span handling for streaming:** regexes only match balanced spans. Unclosed spans (`**bold` with no closing) stay raw. When the closing marker arrives on the next token, the complete-line pass renders the full span correctly.
|
||||||
|
|
||||||
|
### OSC 8 hyperlinks
|
||||||
|
|
||||||
|
```
|
||||||
|
\x1b]8;;{url}\x1b\\{visible_text}\x1b]8;;\x1b\\
|
||||||
|
```
|
||||||
|
|
||||||
|
Emit unconditionally around links and images. Unsupported terminals strip the codes and see plain visible text. Zero degradation.
|
||||||
|
|
||||||
|
### Branching in `highlight_line`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String {
|
||||||
|
if is_code {
|
||||||
|
// unchanged — code block content via language-specific syntect
|
||||||
|
self.highlight_code_syntect(line, syntax)
|
||||||
|
} else if self.options.raw_markdown {
|
||||||
|
// preserved current behavior: syntect on markdown grammar
|
||||||
|
self.highlight_markdown_syntect(line, &self.md_syntax)
|
||||||
|
} else {
|
||||||
|
// new rich rendering path
|
||||||
|
self.render_markdown_line(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Code blocks route to syntect regardless of `raw_markdown` — the flag only affects markdown syntax rendering.
|
||||||
|
|
||||||
|
## Consumers Verified
|
||||||
|
|
||||||
|
Complete map of `MarkdownRender` consumers (from explore agent research). All continue to work without modification because the public API is unchanged:
|
||||||
|
|
||||||
|
1. `src/render/mod.rs:16-33` — `render_stream()` (streaming path via `markdown_stream()`)
|
||||||
|
2. `src/render/stream.rs:67-171` — `markdown_stream_inner()` calls `render.render(head)` and `render.render_line(&buffer)`
|
||||||
|
3. `src/config/app_config.rs:420-429` — `print_markdown()` (CLI one-shot)
|
||||||
|
4. `src/config/request_context.rs:1706-1723` — `session_info()` (`.info` REPL command)
|
||||||
|
5. `src/config/session.rs:278-396` — `Session::render()` (per assistant message)
|
||||||
|
6. `src/render/markdown.rs:311-397` — existing tests
|
||||||
|
|
||||||
|
## Phase 1 Implementation
|
||||||
|
|
||||||
|
### Phase 1.1 — Scope lookup helper + precomputed styles
|
||||||
|
- [x] Add `resolve_scope_style(theme, primary, fallbacks)` helper (generalizes `get_code_color()`)
|
||||||
|
- [x] Add `MarkdownStyles` struct + populate in `MarkdownRender::init` for all 10 constructs
|
||||||
|
- [x] Handle `theme.is_none()` gracefully (all styles = defaults)
|
||||||
|
- [x] Test: verify each style resolves correctly with the built-in dark theme
|
||||||
|
- [x] Test: verify each style falls back correctly with a minimal theme that only defines root scopes
|
||||||
|
|
||||||
|
**Commit:** `feat(render): precompute markdown scope styles for rich rendering`
|
||||||
|
|
||||||
|
### Phase 1.2 — Line-type detection
|
||||||
|
- [x] Add `LineKind` enum + `detect_line_kind()` function
|
||||||
|
- [x] Wire into `check_line` — return `LineKind` alongside existing `LineType`
|
||||||
|
- [x] Test each pattern in isolation (heading, blockquote, bullets, numbered, task, hrule, paragraph)
|
||||||
|
- [x] Test edge cases: `## ` vs `##text` (no space, not a heading), indented list items, empty blockquote
|
||||||
|
|
||||||
|
**Commit:** `feat(render): detect markdown block-level line types`
|
||||||
|
|
||||||
|
### Phase 1.3 — Inline rendering pipeline
|
||||||
|
- [x] Add regex constants (LazyLock) for each inline construct
|
||||||
|
- [x] Add `apply_inline(text: &str, styles: &MarkdownStyles) -> String` that runs the pipeline in order
|
||||||
|
- [x] Test each construct in isolation
|
||||||
|
- [x] Test order-dependence: `**foo `bar` baz**` — bold wraps inline code correctly
|
||||||
|
- [x] Test partial spans stay raw: `**unclosed` → `**unclosed`
|
||||||
|
- [x] Test italic doesn't false-positive: `some_var_name`, `a * b * c` (math-like expression)
|
||||||
|
- [x] Test OSC 8 emission for links and images
|
||||||
|
|
||||||
|
**Commit:** `feat(render): rich inline markdown rendering (bold, italic, code, links)`
|
||||||
|
|
||||||
|
### Phase 1.4 — Block-level rendering
|
||||||
|
- [x] Add `render_markdown_line(line)` that dispatches on `LineKind`
|
||||||
|
- [x] Implement each block transform (heading, blockquote, bullet, numbered, task, hrule, paragraph)
|
||||||
|
- [x] After block transform, always run `apply_inline` on the content
|
||||||
|
- [x] Test each block type with inline styling nested inside (bold in heading, code in list item, link in blockquote)
|
||||||
|
|
||||||
|
**Commit:** `feat(render): rich block-level markdown rendering (headings, quotes, lists, hr)`
|
||||||
|
|
||||||
|
### Phase 1.5 — Wire into `highlight_line` with `raw_markdown` branch
|
||||||
|
- [x] Refactor `highlight_line` to branch on `options.raw_markdown`
|
||||||
|
- [x] Remove `#[allow(dead_code)]` from `RenderOptions::raw_markdown`
|
||||||
|
- [x] Verify all existing tests pass with `raw_markdown: true` (byte-identical output)
|
||||||
|
- [ ] Manual REPL test: send a message with a mix of constructs, verify output matches expectations
|
||||||
|
- [ ] Manual streaming test: verify no flashing, partial spans render smoothly
|
||||||
|
|
||||||
|
**Commit:** `feat(render): activate rich markdown renderer as default`
|
||||||
|
|
||||||
|
### Phase 1.6 — Test coverage
|
||||||
|
- [x] Heading levels 1-6 (transforms + styling)
|
||||||
|
- [x] Bold, italic, inline code, strikethrough
|
||||||
|
- [x] Inline code strips backticks
|
||||||
|
- [x] `some_var_name` NOT italicized
|
||||||
|
- [x] `a * b * c` math not italicized
|
||||||
|
- [x] Blockquote `│ ` prefix
|
||||||
|
- [x] Bullet `•` transformation
|
||||||
|
- [x] Numbered list preservation
|
||||||
|
- [x] Task items `[ ]` / `[✓]`
|
||||||
|
- [x] Horizontal rule
|
||||||
|
- [x] Links: styled text + URL, OSC 8 codes present
|
||||||
|
- [x] Images: `Image: {alt} → {url}` format, OSC 8 codes present
|
||||||
|
- [x] Nested inline in blocks (bold in heading, code in list)
|
||||||
|
- [x] Partial spans in `render_line`
|
||||||
|
- [x] `theme=None` degrades to raw stripped text (no colors, but syntax stripped)
|
||||||
|
- [x] `raw_markdown=true` matches current behavior byte-for-byte
|
||||||
|
|
||||||
|
**Commit:** `test(render): comprehensive coverage for rich markdown renderer`
|
||||||
|
|
||||||
|
## Phase 2 (Follow-up PR) — Tables
|
||||||
|
|
||||||
|
Deferred scope. Rough sketch:
|
||||||
|
|
||||||
|
- Add `Option<TableBuffer>` field to `MarkdownRender`
|
||||||
|
- On table row detection, accumulate rows in buffer (emit raw markdown for now to keep streaming visible)
|
||||||
|
- On non-table line (or blank), flush the buffer: compute column widths, render with box-drawing chars, emit
|
||||||
|
- Handle streaming: use cursor-erase to replace raw rows with rendered table when buffer flushes
|
||||||
|
- Test coverage: single-column, multi-column, alignment specifiers (`:---`, `---:`, `:---:`), empty cells, long content wrapping
|
||||||
|
|
||||||
|
## Success Criteria (Phase 1)
|
||||||
|
|
||||||
|
- [x] All existing tests pass with `raw_markdown: true`
|
||||||
|
- [x] All new tests pass with `raw_markdown: false`
|
||||||
|
- [x] `cargo check` clean
|
||||||
|
- [x] `cargo test` all pass
|
||||||
|
- [ ] Manual REPL test: streaming looks smooth (no flashing, no visible partial spans getting re-rendered)
|
||||||
|
- [ ] Manual REPL test: `.set raw_markdown true` reverts to current behavior
|
||||||
|
- [ ] Manual test: user's custom theme colors apply to headings/bold/etc. (not just default)
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
Append-only. One entry per commit or session.
|
||||||
|
|
||||||
|
### 2026-07-22 — Planning complete
|
||||||
|
- Scoped implementation via research (glamour source, syntect scope conventions, current renderer consumers)
|
||||||
|
- Resolved 3 open design questions (tables deferred, glamour hash prefixes matched, OSC 8 with fallback)
|
||||||
|
- Wrote this plan file
|
||||||
|
- Next: hand to Momus for review before starting Phase 1.1
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 1.1 complete (`d2940a8`)
|
||||||
|
- Added `resolve_scope_style` helper + `MarkdownStyles` struct with 10 constructs, precomputed once in `MarkdownRender::init`; new struct is `#[allow(dead_code)]` until Phase 1.5 wires it in. 6 new tests cover primary/fallback/default paths, `theme.is_none()`, built-in dark theme, and a minimal-root-scopes theme.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 1.2 complete (`f40ba4c`)
|
||||||
|
- Added `LineKind` enum (Heading/Blockquote/TaskItem/BulletItem/NumberedItem/HorizontalRule/Paragraph) and `detect_line_kind()` using `fancy_regex` for the 6 block patterns. Wired into `check_line` — signature now returns `(LineType, LineKind, Option<SyntaxReference>, bool)`; callers ignore `LineKind` with `_` until Phase 1.4. 8 new tests cover each pattern plus edge cases (`##notheading`, `-nospace`, `--`, indented items, empty blockquote).
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 1.3 complete (`89db5b3`)
|
||||||
|
- Added inline regexes (INLINE_CODE, IMAGE, LINK, BOLD_AST, BOLD_US, ITALIC_AST, ITALIC_US, STRIKETHROUGH, CODE_PLACEHOLDER) and `apply_inline()` running the plan's 6-step pipeline. Refined italic regexes with `(?!\s)` opener + `(?<!\s)` closer to prevent `a * b * c` false-positives while still requiring the word-boundary lookbehind for `some_var_name`. Inline code is masked with `\x00C{idx}\x00` placeholders before other transforms so its content is never re-parsed. Links/images wrap in OSC 8 hyperlink codes. 15 new tests cover each construct, order-dependence, partial spans, italic false-positives, OSC 8 emission, and image-before-link ordering.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 1.4 complete (`9890cf0`)
|
||||||
|
- Added `render_markdown_line(line, kind, styles)` dispatcher plus per-`LineKind` block renderers (`render_heading`, `render_blockquote`, `render_bullet`, `render_numbered`, `render_task`, `render_hrule`). H1 gets space-padded, H2-6 keep their `##...` prefix; blockquotes get `│ `; bullets → `•`; numbered items keep the number and style only the `.`; task items → `[ ]` / `[✓]`; hrules render as `────────`. All block variants delegate leftover content to `apply_inline`. 15 new tests cover each block type, indent preservation, and nested inline (code in bullet, link in blockquote).
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 1.5 complete (`d65d63e`)
|
||||||
|
- Wired the rich renderer into `render_line` / `render_line_mut`: code lines still route to syntect; non-code lines branch on `options.raw_markdown` (true → existing markdown-grammar syntect path, false → `render_rich_markdown_line`). Removed `#[allow(dead_code)]` from `RenderOptions::raw_markdown`, `MarkdownStyles`, `LineKind`, `detect_line_kind`, `render_markdown_line`, `apply_inline`, and the `styles` field. Updated the 3 existing tests (`no_theme`, `no_wrap_code`, `wrap_all`) to set `raw_markdown: true` — they still produce byte-identical output, proving the raw path is preserved. Manual REPL/streaming tests deferred to user.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 1.6 complete (`b0eeba1`)
|
||||||
|
- 6 more tests filling out the coverage checklist: bold nested inside a heading, partial bold/link spans via `render_line` (streaming path), rich rendering with `theme=None` still strips syntax and emits block glyphs, rich vs raw paths diverge on the same input, and fenced code blocks still route through syntect. Total: 55 markdown tests, 1207 total tests pass, `cargo check` clean.
|
||||||
|
|
||||||
|
### 2026-07-22 — Phase 2 complete (Phase 1 successor shipped)
|
||||||
|
- Phase 2 (tables + hanging-indent list/blockquote wrapping) is complete on top of this foundation. See `rich-markdown-renderer-tables.md` for the full plan and per-sub-phase progress log. Final commits: `fcc4a1d` (2.1) → `7671d28` (2.2) → `c062f34` (2.3) → `cdfaa0f` (2.4) → `bf06d5e` (2.5) → `d790782` (2.6) → `e82e5ab` (2.7). Total markdown tests grew from 55 → 94; total test suite 1207 → 1246, all passing, `cargo check` clean.
|
||||||
Generated
+84
@@ -80,6 +80,15 @@ dependencies = [
|
|||||||
"rayon",
|
"rayon",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ansi-str"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d"
|
||||||
|
dependencies = [
|
||||||
|
"ansitok",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ansi_colours"
|
name = "ansi_colours"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -89,6 +98,16 @@ dependencies = [
|
|||||||
"rgb",
|
"rgb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ansitok"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
|
||||||
|
dependencies = [
|
||||||
|
"nom 7.1.3",
|
||||||
|
"vte",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -219,6 +238,12 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.42"
|
version = "0.4.42"
|
||||||
@@ -1016,6 +1041,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -1281,6 +1312,19 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "comfy-table"
|
||||||
|
version = "7.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
||||||
|
dependencies = [
|
||||||
|
"ansi-str",
|
||||||
|
"console",
|
||||||
|
"crossterm",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.38"
|
version = "0.4.38"
|
||||||
@@ -1429,6 +1473,7 @@ dependencies = [
|
|||||||
"clap_complete",
|
"clap_complete",
|
||||||
"clap_complete_nushell",
|
"clap_complete_nushell",
|
||||||
"colored",
|
"colored",
|
||||||
|
"comfy-table",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
"duct",
|
"duct",
|
||||||
@@ -1457,6 +1502,7 @@ dependencies = [
|
|||||||
"path-absolutize",
|
"path-absolutize",
|
||||||
"petgraph 0.7.1",
|
"petgraph 0.7.1",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
|
"qrcode",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"reedline",
|
"reedline",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
@@ -3046,6 +3092,18 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -3576,6 +3634,16 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399"
|
checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -4418,6 +4486,21 @@ dependencies = [
|
|||||||
"prost",
|
"prost",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
dependencies = [
|
||||||
|
"image",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -6613,6 +6696,7 @@ version = "0.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ exclude = [".github", "CONTRIBUTING.md"]
|
|||||||
anyhow = "1.0.69"
|
anyhow = "1.0.69"
|
||||||
bytes = "1.4.0"
|
bytes = "1.4.0"
|
||||||
clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] }
|
clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] }
|
||||||
|
comfy-table = { version = "7.2.2", features = ["custom_styling"] }
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
dunce = "1.0.5"
|
dunce = "1.0.5"
|
||||||
futures-util = "0.3.29"
|
futures-util = "0.3.29"
|
||||||
@@ -107,6 +108,7 @@ self_update = { version = "0.44", default-features = false, features = [
|
|||||||
"archive-zip",
|
"archive-zip",
|
||||||
"compression-zip-deflate",
|
"compression-zip-deflate",
|
||||||
] }
|
] }
|
||||||
|
qrcode = "0.14"
|
||||||
|
|
||||||
[dependencies.reqwest]
|
[dependencies.reqwest]
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
ARG COYOTE_VERSION
|
||||||
|
FROM docker/sandbox-templates:shell-docker
|
||||||
|
|
||||||
|
ARG COYOTE_VERSION
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
ENV PATH="/home/agent/.cargo/bin:/home/agent/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
jq curl git \
|
||||||
|
build-essential pkg-config \
|
||||||
|
cmake \
|
||||||
|
clang libclang-dev \
|
||||||
|
musl-tools \
|
||||||
|
libssl-dev \
|
||||||
|
pandoc \
|
||||||
|
bzip2 \
|
||||||
|
nano && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -euo pipefail; \
|
||||||
|
USQL_VERSION=0.21.4; \
|
||||||
|
case "${TARGETARCH}" in \
|
||||||
|
amd64) USQL_ARCH=amd64 ;; \
|
||||||
|
arm64) USQL_ARCH=arm64 ;; \
|
||||||
|
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
TMPDIR=$(mktemp -d); \
|
||||||
|
curl -fsSL --retry 3 \
|
||||||
|
"https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" \
|
||||||
|
-o "$TMPDIR/usql.tar.bz2"; \
|
||||||
|
tar -xjf "$TMPDIR/usql.tar.bz2" -C "$TMPDIR"; \
|
||||||
|
install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql; \
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
USER 1000
|
||||||
|
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||||
|
printf '#!/bin/sh\nexec uv tool run "$@"\n' > "$HOME/.local/bin/uvx" && \
|
||||||
|
chmod +x "$HOME/.local/bin/uvx"
|
||||||
|
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||||
|
sh -s -- -y --default-toolchain stable --profile minimal && \
|
||||||
|
. "$HOME/.cargo/env" && \
|
||||||
|
cargo install --locked iwec && \
|
||||||
|
cargo install --locked ast-grep
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN set -euo pipefail; \
|
||||||
|
case "${TARGETARCH}" in \
|
||||||
|
amd64) MUSL_TARGET=x86_64-unknown-linux-musl ;; \
|
||||||
|
arm64) MUSL_TARGET=aarch64-unknown-linux-musl ;; \
|
||||||
|
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
TMPDIR=$(mktemp -d); \
|
||||||
|
curl -fsSL --retry 3 \
|
||||||
|
"https://github.com/Dark-Alex-17/coyote/releases/download/v${COYOTE_VERSION}/coyote-${MUSL_TARGET}.tar.gz" \
|
||||||
|
-o "$TMPDIR/coyote.tar.gz"; \
|
||||||
|
tar -xzf "$TMPDIR/coyote.tar.gz" -C "$TMPDIR"; \
|
||||||
|
install -m 0755 "$TMPDIR/coyote" /home/agent/.cargo/bin/coyote; \
|
||||||
|
chown 1000:1000 /home/agent/.cargo/bin/coyote; \
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
USER 1000
|
||||||
|
|
||||||
|
ENTRYPOINT ["coyote"]
|
||||||
@@ -5,6 +5,7 @@
|
|||||||

|

|
||||||

|

|
||||||
[](https://github.com/Dark-Alex-17/coyote/releases)
|
[](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 &
|
Coyote is an all-in-one, batteries-included, LLM CLI tool featuring Shell Assistant, CLI & REPL Mode, RAG, AI Tools &
|
||||||
Agents, and More.
|
Agents, and More.
|
||||||
@@ -38,6 +39,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
|||||||
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
||||||
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
|
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
|
||||||
* [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`.
|
* [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`.
|
||||||
|
* [Workspace Instructions](https://github.com/Dark-Alex-17/coyote/wiki/Workspace-Instructions): Human-curated project instructions (`COYOTE.md`) injected into every prompt, with `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` fallbacks for cross-tool compatibility. Scaffold with `coyote --init-instructions`.
|
||||||
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
|
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
|
||||||
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
|
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
|
||||||
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
||||||
@@ -60,7 +62,7 @@ Coyote requires the following tools to be installed on your system:
|
|||||||
* [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
* [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||||
* `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
* `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||||
* [iwe](https://github.com/iwe-org/iwe) (`iwec`, for the built-in `iwe` MCP server that navigates large markdown knowledgebases)
|
* [iwe](https://github.com/iwe-org/iwe) (`iwec`, for the built-in `iwe` MCP server that navigates large markdown knowledgebases)
|
||||||
* **Homebrew:** `brew tap iwe-org/iwe && brew install iwe`
|
* **Homebrew:** `brew tap iwe-org/iwe && brew trust --formula iwe-org/iwe/iwe && brew install iwe`
|
||||||
* **Cargo:** `cargo install iwec`
|
* **Cargo:** `cargo install iwec`
|
||||||
* [ast-grep](https://ast-grep.github.io/) (for the built-in `ast_grep` structural code search tool, used by the `explore` agent)
|
* [ast-grep](https://ast-grep.github.io/) (for the built-in `ast_grep` structural code search tool, used by the `explore` agent)
|
||||||
* **Homebrew:** `brew install ast-grep`
|
* **Homebrew:** `brew install ast-grep`
|
||||||
@@ -100,6 +102,32 @@ To upgrade `coyote` using Homebrew:
|
|||||||
brew upgrade coyote
|
brew upgrade coyote
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
Coyote is available as a Docker image on Docker Hub (`darkalex17/coyote`) for Linux amd64 and arm64.
|
||||||
|
Useful for CI, ephemeral environments, or anywhere you prefer not to install it natively.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull darkalex17/coyote
|
||||||
|
docker run --rm -it darkalex17/coyote
|
||||||
|
```
|
||||||
|
|
||||||
|
To persist your configuration across container runs, mount your existing config directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -it \
|
||||||
|
-v ~/.config/coyote:/home/agent/.config/coyote \
|
||||||
|
darkalex17/coyote
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use the local vault provider and want your vault credentials available in the container, also mount the password file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -it \
|
||||||
|
-v ~/.config/coyote:/home/agent/.config/coyote \
|
||||||
|
-v ~/.coyote_password:/home/agent/.coyote_password:ro \
|
||||||
|
darkalex17/coyote
|
||||||
|
```
|
||||||
|
|
||||||
### Scripts
|
### Scripts
|
||||||
#### Linux/MacOS (`bash`)
|
#### Linux/MacOS (`bash`)
|
||||||
You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
|
You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ agents while handling coordination and final reporting.
|
|||||||
## Pro-Tip: Use an IDE MCP Server for Improved Performance
|
## Pro-Tip: Use an IDE MCP Server for Improved Performance
|
||||||
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
|
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
|
||||||
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
|
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
|
||||||
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
|
server to your config (see the [MCP Server docs](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers) to see how to configure
|
||||||
them), and modify the agent definition to look like this:
|
them), and modify the agent definition to look like this:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ variables:
|
|||||||
default: '.'
|
default: '.'
|
||||||
|
|
||||||
global_tools:
|
global_tools:
|
||||||
|
- ast_grep.sh
|
||||||
- fs_read.sh
|
- fs_read.sh
|
||||||
- fs_cat.sh
|
- fs_cat.sh
|
||||||
- fs_grep.sh
|
- fs_grep.sh
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: coder
|
|||||||
description: |
|
description: |
|
||||||
Implementation agent. Plans, implements, and runs build + tests in a
|
Implementation agent. Plans, implements, and runs build + tests in a
|
||||||
bounded fix-loop until verified. Designed to be delegated to by sisyphus.
|
bounded fix-loop until verified. Designed to be delegated to by sisyphus.
|
||||||
version: "1.0"
|
version: '1.0'
|
||||||
|
|
||||||
global_tools:
|
global_tools:
|
||||||
|
- ast_grep.sh
|
||||||
- fs_cat.sh
|
- fs_cat.sh
|
||||||
- fs_ls.sh
|
- fs_ls.sh
|
||||||
- fs_write.sh
|
- fs_write.sh
|
||||||
@@ -25,7 +25,7 @@ variables:
|
|||||||
Absolute path to the project directory. Defaults to "." which is the
|
Absolute path to the project directory. Defaults to "." which is the
|
||||||
directory you invoked `coyote` from. Override at runtime with
|
directory you invoked `coyote` from. Override at runtime with
|
||||||
`coyote -a coder --agent-variable project_dir /abs/path "..."`.
|
`coyote -a coder --agent-variable project_dir /abs/path "..."`.
|
||||||
default: "."
|
default: '.'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
max_loop_iterations: 20
|
max_loop_iterations: 20
|
||||||
@@ -34,14 +34,14 @@ settings:
|
|||||||
timeout: 1800
|
timeout: 1800
|
||||||
|
|
||||||
initial_state:
|
initial_state:
|
||||||
project_dir: ""
|
project_dir: ''
|
||||||
fix_attempts: 0
|
fix_attempts: 0
|
||||||
max_fix_attempts: 3
|
max_fix_attempts: 3
|
||||||
fix_instructions: ""
|
fix_instructions: ''
|
||||||
build_output: ""
|
build_output: ''
|
||||||
tests_output: ""
|
tests_output: ''
|
||||||
last_node_output: ""
|
last_node_output: ''
|
||||||
plan_summary: ""
|
plan_summary: ''
|
||||||
files_to_modify: []
|
files_to_modify: []
|
||||||
files_to_create: []
|
files_to_create: []
|
||||||
risks: []
|
risks: []
|
||||||
@@ -49,7 +49,7 @@ initial_state:
|
|||||||
review_attempts: 0
|
review_attempts: 0
|
||||||
max_review_attempts: 1
|
max_review_attempts: 1
|
||||||
review_clean: true
|
review_clean: true
|
||||||
review_notes: ""
|
review_notes: ''
|
||||||
|
|
||||||
start: resolve_paths
|
start: resolve_paths
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ nodes:
|
|||||||
etc. Empty list is fine.
|
etc. Empty list is fine.
|
||||||
|
|
||||||
Project directory: {{project_dir}}
|
Project directory: {{project_dir}}
|
||||||
prompt: "{{initial_prompt}}"
|
prompt: '{{initial_prompt}}'
|
||||||
tools: []
|
tools: []
|
||||||
output_schema:
|
output_schema:
|
||||||
type: object
|
type: object
|
||||||
@@ -98,20 +98,27 @@ nodes:
|
|||||||
description: 1-3 sentences summarizing what will be done
|
description: 1-3 sentences summarizing what will be done
|
||||||
files_to_modify:
|
files_to_modify:
|
||||||
type: array
|
type: array
|
||||||
items: {type: string}
|
items: { type: string }
|
||||||
files_to_create:
|
files_to_create:
|
||||||
type: array
|
type: array
|
||||||
items: {type: string}
|
items: { type: string }
|
||||||
complexity_score:
|
complexity_score:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
maximum: 10
|
maximum: 10
|
||||||
risks:
|
risks:
|
||||||
type: array
|
type: array
|
||||||
items: {type: string}
|
items: { type: string }
|
||||||
required: [plan_summary, files_to_modify, files_to_create, complexity_score, risks]
|
required:
|
||||||
|
[
|
||||||
|
plan_summary,
|
||||||
|
files_to_modify,
|
||||||
|
files_to_create,
|
||||||
|
complexity_score,
|
||||||
|
risks,
|
||||||
|
]
|
||||||
state_updates:
|
state_updates:
|
||||||
last_node_output: "{{output}}"
|
last_node_output: '{{output}}'
|
||||||
fallback: end_failure
|
fallback: end_failure
|
||||||
next: route_complexity
|
next: route_complexity
|
||||||
|
|
||||||
@@ -144,11 +151,11 @@ nodes:
|
|||||||
|
|
||||||
Approve this plan?
|
Approve this plan?
|
||||||
options:
|
options:
|
||||||
- "yes"
|
- 'yes'
|
||||||
- "no"
|
- 'no'
|
||||||
routes:
|
routes:
|
||||||
"yes": implement
|
'yes': implement
|
||||||
"no": end_rejected
|
'no': end_rejected
|
||||||
on_other: end_rejected
|
on_other: end_rejected
|
||||||
|
|
||||||
implement:
|
implement:
|
||||||
@@ -243,7 +250,7 @@ nodes:
|
|||||||
- execute_command
|
- execute_command
|
||||||
max_iterations: 30
|
max_iterations: 30
|
||||||
state_updates:
|
state_updates:
|
||||||
last_node_output: "{{output}}"
|
last_node_output: '{{output}}'
|
||||||
fallback: end_failure
|
fallback: end_failure
|
||||||
next: verify_build
|
next: verify_build
|
||||||
|
|
||||||
@@ -326,7 +333,7 @@ nodes:
|
|||||||
description: Concrete issues found, one per line as file:line - description. Empty when review_clean is true.
|
description: Concrete issues found, one per line as file:line - description. Empty when review_clean is true.
|
||||||
required: [review_clean, review_notes]
|
required: [review_clean, review_notes]
|
||||||
state_updates:
|
state_updates:
|
||||||
last_node_output: "{{output}}"
|
last_node_output: '{{output}}'
|
||||||
fallback: end_success
|
fallback: end_success
|
||||||
next: route_review_result
|
next: route_review_result
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: explore
|
name: explore
|
||||||
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out 2-5 in parallel by orchestrators.
|
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out in parallel by orchestrators — scale to the number of distinct search angles the task requires.
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
|
|
||||||
skills_enabled: true
|
skills_enabled: true
|
||||||
@@ -14,6 +14,7 @@ variables:
|
|||||||
mcp_servers:
|
mcp_servers:
|
||||||
- ddg-search
|
- ddg-search
|
||||||
global_tools:
|
global_tools:
|
||||||
|
- ast_grep.sh
|
||||||
- fs_read.sh
|
- fs_read.sh
|
||||||
- fs_cat.sh
|
- fs_cat.sh
|
||||||
- fs_grep.sh
|
- fs_grep.sh
|
||||||
@@ -34,7 +35,7 @@ instructions: |
|
|||||||
|
|
||||||
## You may be one of many parallel explorers
|
## You may be one of many parallel explorers
|
||||||
|
|
||||||
Orchestrators (like Sisyphus) often fan out 2-5 explore agents at once, each covering a different angle of the same question. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling.
|
Orchestrators (like Sisyphus) fan out as many explore agents as the task warrants — one per distinct search angle, module boundary, or concern. You may be one of many running in parallel. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling.
|
||||||
|
|
||||||
If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract.
|
If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract.
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ one file while communicating with sibling agents to catch issues that span multi
|
|||||||
## Pro-Tip: Use an IDE MCP Server for Improved Performance
|
## Pro-Tip: Use an IDE MCP Server for Improved Performance
|
||||||
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
|
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
|
||||||
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
|
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
|
||||||
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
|
server to your config (see the [MCP Server docs](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers) to see how to configure
|
||||||
them), and modify the agent definition to look like this:
|
them), and modify the agent definition to look like this:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ description: |
|
|||||||
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
|
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
|
||||||
involved.
|
involved.
|
||||||
|
|
||||||
Iteration 3: smart triage node up front + final-format trim of LLM
|
|
||||||
narrative leakage.
|
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
|
|
||||||
global_tools:
|
global_tools:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ variables:
|
|||||||
mcp_servers:
|
mcp_servers:
|
||||||
- ddg-search
|
- ddg-search
|
||||||
global_tools:
|
global_tools:
|
||||||
|
- ast_grep.sh
|
||||||
- fs_read.sh
|
- fs_read.sh
|
||||||
- fs_cat.sh
|
- fs_cat.sh
|
||||||
- fs_grep.sh
|
- fs_grep.sh
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ max_auto_continues: 25
|
|||||||
inject_todo_instructions: true
|
inject_todo_instructions: true
|
||||||
|
|
||||||
can_spawn_agents: true
|
can_spawn_agents: true
|
||||||
|
spawnable_agents:
|
||||||
|
- explore
|
||||||
|
- librarian
|
||||||
|
- coder
|
||||||
|
- oracle
|
||||||
|
- code-reviewer
|
||||||
|
- step-runner
|
||||||
max_concurrent_agents: 4
|
max_concurrent_agents: 4
|
||||||
max_agent_depth: 3
|
max_agent_depth: 3
|
||||||
inject_spawn_instructions: true
|
inject_spawn_instructions: true
|
||||||
@@ -39,6 +46,7 @@ variables:
|
|||||||
mcp_servers:
|
mcp_servers:
|
||||||
- ddg-search
|
- ddg-search
|
||||||
global_tools:
|
global_tools:
|
||||||
|
- ast_grep.sh
|
||||||
- fs_read.sh
|
- fs_read.sh
|
||||||
- fs_grep.sh
|
- fs_grep.sh
|
||||||
- fs_glob.sh
|
- fs_glob.sh
|
||||||
@@ -128,8 +136,8 @@ instructions: |
|
|||||||
|
|
||||||
| Agent | Use For | Characteristics |
|
| Agent | Use For | Characteristics |
|
||||||
|-------|---------|-----------------|
|
|-------|---------|-----------------|
|
||||||
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out 2-5 in parallel |
|
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 5–15+. |
|
||||||
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out 1-3 in parallel |
|
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 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 |
|
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
|
||||||
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
|
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
|
||||||
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
|
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
|
||||||
@@ -194,7 +202,17 @@ instructions: |
|
|||||||
|
|
||||||
## Phase 4 - Parallel Research
|
## Phase 4 - Parallel Research
|
||||||
|
|
||||||
When delegating exploration, load `parallel-research` skill, then fan out 2-5 `explore` agents in parallel, each scoped to a different angle. Each gets a NARROW slice.
|
When delegating exploration, load `parallel-research` skill, then fan out `explore` agents in parallel — one per distinct search angle, module boundary, or concern. Each gets a NARROW slice. Scale to the task:
|
||||||
|
|
||||||
|
| Task scope | Suggested fan-out |
|
||||||
|
|---|---|
|
||||||
|
| Single feature, known location | 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
|
### The wait protocol
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ description: |
|
|||||||
implement (coder) -> verify -> edge-case sweep -> optional independent
|
implement (coder) -> verify -> edge-case sweep -> optional independent
|
||||||
review -> evidence-backed handoff -> user approval gate. Designed to be
|
review -> evidence-backed handoff -> user approval gate. Designed to be
|
||||||
delegated to by sisyphus.
|
delegated to by sisyphus.
|
||||||
version: "1.0"
|
version: '1.0'
|
||||||
|
|
||||||
global_tools:
|
global_tools:
|
||||||
|
- ast_grep.sh
|
||||||
- fs_cat.sh
|
- fs_cat.sh
|
||||||
- fs_ls.sh
|
- fs_ls.sh
|
||||||
- fs_write.sh
|
- fs_write.sh
|
||||||
@@ -28,18 +28,18 @@ variables:
|
|||||||
coyote was invoked from). The coder sub-agent resolves its own
|
coyote was invoked from). The coder sub-agent resolves its own
|
||||||
project_dir the same way, so invoke step-runner FROM the project root
|
project_dir the same way, so invoke step-runner FROM the project root
|
||||||
unless you override this for both.
|
unless you override this for both.
|
||||||
default: "."
|
default: '.'
|
||||||
- name: plans_dir
|
- name: plans_dir
|
||||||
description: |
|
description: |
|
||||||
Path to the plan repo. Relative paths resolve against project_dir.
|
Path to the plan repo. Relative paths resolve against project_dir.
|
||||||
Expected layout: <plans_dir>/steps/NN-<slug>.md,
|
Expected layout: <plans_dir>/steps/NN-<slug>.md,
|
||||||
<plans_dir>/handoffs/, <plans_dir>/NOTES.md.
|
<plans_dir>/handoffs/, <plans_dir>/NOTES.md.
|
||||||
default: "plans"
|
default: 'plans'
|
||||||
- name: step
|
- name: step
|
||||||
description: |
|
description: |
|
||||||
Which step to execute: a step number, or "next" to pick the first
|
Which step to execute: a step number, or "next" to pick the first
|
||||||
in-progress (resume) or pending step plan.
|
in-progress (resume) or pending step plan.
|
||||||
default: "next"
|
default: 'next'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
max_loop_iterations: 20
|
max_loop_iterations: 20
|
||||||
@@ -48,45 +48,45 @@ settings:
|
|||||||
timeout: 7200
|
timeout: 7200
|
||||||
|
|
||||||
initial_state:
|
initial_state:
|
||||||
project_dir: ""
|
project_dir: ''
|
||||||
plans_dir: ""
|
plans_dir: ''
|
||||||
step_number: 0
|
step_number: 0
|
||||||
step_slug: ""
|
step_slug: ''
|
||||||
step_title: ""
|
step_title: ''
|
||||||
step_plan_path: ""
|
step_plan_path: ''
|
||||||
step_plan: ""
|
step_plan: ''
|
||||||
prev_handoff_path: "(none)"
|
prev_handoff_path: '(none)'
|
||||||
prev_handoff: "(none - this is the first step)"
|
prev_handoff: '(none - this is the first step)'
|
||||||
notes_path: ""
|
notes_path: ''
|
||||||
notes: "(none)"
|
notes: '(none)'
|
||||||
handoff_path: ""
|
handoff_path: ''
|
||||||
blocking_reason: ""
|
blocking_reason: ''
|
||||||
plan_summary: ""
|
plan_summary: ''
|
||||||
implementation_brief: ""
|
implementation_brief: ''
|
||||||
staleness_report: ""
|
staleness_report: ''
|
||||||
has_major_deviation: false
|
has_major_deviation: false
|
||||||
deviation_summary: ""
|
deviation_summary: ''
|
||||||
user_feedback: ""
|
user_feedback: ''
|
||||||
fix_instructions: ""
|
fix_instructions: ''
|
||||||
fix_attempts: 0
|
fix_attempts: 0
|
||||||
max_fix_attempts: 2
|
max_fix_attempts: 2
|
||||||
coder_result: ""
|
coder_result: ''
|
||||||
format_output: ""
|
format_output: ''
|
||||||
lint_ok: true
|
lint_ok: true
|
||||||
lint_output: ""
|
lint_output: ''
|
||||||
build_ok: true
|
build_ok: true
|
||||||
build_output: ""
|
build_output: ''
|
||||||
tests_ok: true
|
tests_ok: true
|
||||||
tests_output: ""
|
tests_output: ''
|
||||||
edge_case_report: ""
|
edge_case_report: ''
|
||||||
downstream_updates: ""
|
downstream_updates: ''
|
||||||
needs_independent_review: false
|
needs_independent_review: false
|
||||||
review_report: ""
|
review_report: ''
|
||||||
review_attempts: 0
|
review_attempts: 0
|
||||||
max_review_attempts: 1
|
max_review_attempts: 1
|
||||||
handoff_attempts: 0
|
handoff_attempts: 0
|
||||||
handoff_fix: ""
|
handoff_fix: ''
|
||||||
step_summary: ""
|
step_summary: ''
|
||||||
|
|
||||||
start: resolve_step
|
start: resolve_step
|
||||||
|
|
||||||
@@ -114,11 +114,11 @@ nodes:
|
|||||||
|
|
||||||
Proceed anyway?
|
Proceed anyway?
|
||||||
options:
|
options:
|
||||||
- "yes"
|
- 'yes'
|
||||||
- "no"
|
- 'no'
|
||||||
routes:
|
routes:
|
||||||
"yes": orient
|
'yes': orient
|
||||||
"no": end_blocked
|
'no': end_blocked
|
||||||
on_other: end_blocked
|
on_other: end_blocked
|
||||||
|
|
||||||
orient:
|
orient:
|
||||||
@@ -183,7 +183,14 @@ nodes:
|
|||||||
deviation_summary:
|
deviation_summary:
|
||||||
type: string
|
type: string
|
||||||
description: Major deviations only, with the plan claim vs current reality. Empty when none
|
description: Major deviations only, with the plan claim vs current reality. Empty when none
|
||||||
required: [plan_summary, implementation_brief, staleness_report, has_major_deviation, deviation_summary]
|
required:
|
||||||
|
[
|
||||||
|
plan_summary,
|
||||||
|
implementation_brief,
|
||||||
|
staleness_report,
|
||||||
|
has_major_deviation,
|
||||||
|
deviation_summary,
|
||||||
|
]
|
||||||
fallback: end_failure
|
fallback: end_failure
|
||||||
next: route_staleness
|
next: route_staleness
|
||||||
|
|
||||||
@@ -211,14 +218,14 @@ nodes:
|
|||||||
Proceed with the corrected brief? (Answer with anything else to give
|
Proceed with the corrected brief? (Answer with anything else to give
|
||||||
your own guidance to the implementer.)
|
your own guidance to the implementer.)
|
||||||
options:
|
options:
|
||||||
- "proceed"
|
- 'proceed'
|
||||||
- "abort"
|
- 'abort'
|
||||||
routes:
|
routes:
|
||||||
"proceed": implement
|
'proceed': implement
|
||||||
"abort": end_rejected
|
'abort': end_rejected
|
||||||
on_other: implement
|
on_other: implement
|
||||||
state_updates:
|
state_updates:
|
||||||
user_feedback: "{{choice}}"
|
user_feedback: '{{choice}}'
|
||||||
|
|
||||||
implement:
|
implement:
|
||||||
id: implement
|
id: implement
|
||||||
@@ -262,7 +269,7 @@ nodes:
|
|||||||
{{fix_instructions}}
|
{{fix_instructions}}
|
||||||
timeout: 3600
|
timeout: 3600
|
||||||
state_updates:
|
state_updates:
|
||||||
coder_result: "{{output}}"
|
coder_result: '{{output}}'
|
||||||
next: route_coder_result
|
next: route_coder_result
|
||||||
|
|
||||||
route_coder_result:
|
route_coder_result:
|
||||||
@@ -399,7 +406,7 @@ nodes:
|
|||||||
Preserve severity tags in your findings.
|
Preserve severity tags in your findings.
|
||||||
timeout: 1200
|
timeout: 1200
|
||||||
state_updates:
|
state_updates:
|
||||||
review_report: "{{output}}"
|
review_report: '{{output}}'
|
||||||
next: route_review
|
next: route_review
|
||||||
|
|
||||||
route_review:
|
route_review:
|
||||||
@@ -517,23 +524,23 @@ nodes:
|
|||||||
Approve this step? (Answer with anything else to send revision
|
Approve this step? (Answer with anything else to send revision
|
||||||
instructions straight to the implementer.)
|
instructions straight to the implementer.)
|
||||||
options:
|
options:
|
||||||
- "approve"
|
- 'approve'
|
||||||
- "revise"
|
- 'revise'
|
||||||
routes:
|
routes:
|
||||||
"approve": end_success
|
'approve': end_success
|
||||||
"revise": get_revision
|
'revise': get_revision
|
||||||
on_other: revise_from_choice
|
on_other: revise_from_choice
|
||||||
state_updates:
|
state_updates:
|
||||||
user_feedback: "{{choice}}"
|
user_feedback: '{{choice}}'
|
||||||
|
|
||||||
get_revision:
|
get_revision:
|
||||||
id: get_revision
|
id: get_revision
|
||||||
type: input
|
type: input
|
||||||
description: Collect revision instructions, then loop back through implement -> verify -> handoff.
|
description: Collect revision instructions, then loop back through implement -> verify -> handoff.
|
||||||
question: "What should change? Your comments go to the implementer verbatim."
|
question: 'What should change? Your comments go to the implementer verbatim.'
|
||||||
validation: "len(input) > 0"
|
validation: 'len(input) > 0'
|
||||||
state_updates:
|
state_updates:
|
||||||
fix_instructions: "{{input}}"
|
fix_instructions: '{{input}}'
|
||||||
next: implement
|
next: implement
|
||||||
|
|
||||||
revise_from_choice:
|
revise_from_choice:
|
||||||
|
|||||||
@@ -10,5 +10,13 @@ set -e
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
# shellcheck disable=SC2154
|
# shellcheck disable=SC2154
|
||||||
cat "$argc_path" >> "$LLM_OUTPUT" 2>&1 || echo "No such file or path: $argc_path" >> "$LLM_OUTPUT"
|
local path="$argc_path"
|
||||||
|
|
||||||
|
# An empty result is shown to the model as the opaque literal "DONE"; emit a note instead.
|
||||||
|
if [[ -f "$path" && ! -s "$path" ]]; then
|
||||||
|
echo "(empty file: $path)" >> "$LLM_OUTPUT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat "$path" >> "$LLM_OUTPUT" 2>&1 || echo "No such file or path: $path" >> "$LLM_OUTPUT"
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,8 @@ main() {
|
|||||||
local search_path="${argc_path:-.}"
|
local search_path="${argc_path:-.}"
|
||||||
|
|
||||||
if [[ ! -d "$search_path" ]]; then
|
if [[ ! -d "$search_path" ]]; then
|
||||||
echo "Error: directory not found: $search_path" >> "$LLM_OUTPUT"
|
echo "Error: directory not found: $search_path" >&2
|
||||||
return 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local results
|
local results
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ main() {
|
|||||||
local include_filter="${argc_include:-}"
|
local include_filter="${argc_include:-}"
|
||||||
|
|
||||||
if [[ ! -e "$search_path" ]]; then
|
if [[ ! -e "$search_path" ]]; then
|
||||||
echo "Error: path not found: $search_path" >> "$LLM_OUTPUT"
|
echo "Error: path not found: $search_path" >&2
|
||||||
return 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local grep_args=(-nH --color=never)
|
local grep_args=(-nH --color=never)
|
||||||
|
|||||||
@@ -9,5 +9,18 @@ set -e
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
# shellcheck disable=SC2154
|
# shellcheck disable=SC2154
|
||||||
ls -1 "$argc_path" >> "$LLM_OUTPUT" 2>&1 || echo "No such path: $argc_path" >> "$LLM_OUTPUT"
|
local path="$argc_path"
|
||||||
|
local output
|
||||||
|
|
||||||
|
if ! output=$(ls -1 "$path" 2>&1); then
|
||||||
|
echo "$output" >> "$LLM_OUTPUT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# An empty result is shown to the model as the opaque literal "DONE"; emit a note instead.
|
||||||
|
if [[ -z "$output" ]]; then
|
||||||
|
echo "(empty directory: $path)" >> "$LLM_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "$output" >> "$LLM_OUTPUT"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,8 @@ set -e
|
|||||||
# Use the grep tool to find specific content before reading, then read with offset to target the relevant section.
|
# Use the grep tool to find specific content before reading, then read with offset to target the relevant section.
|
||||||
|
|
||||||
# @option --path! The absolute path to the file or directory to read
|
# @option --path! The absolute path to the file or directory to read
|
||||||
# @option --offset The line number to start reading from (1-indexed, default: 1)
|
# @option --offset <INT> The line number to start reading from (1-indexed, default: 1)
|
||||||
# @option --limit The maximum number of lines to read (default: 2000)
|
# @option --limit <INT> The maximum number of lines to read (default: 2000)
|
||||||
|
|
||||||
# @env LLM_OUTPUT=/dev/stdout The output path
|
# @env LLM_OUTPUT=/dev/stdout The output path
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ main() {
|
|||||||
local limit="${argc_limit:-2000}"
|
local limit="${argc_limit:-2000}"
|
||||||
|
|
||||||
if [[ ! -e "$target" ]]; then
|
if [[ ! -e "$target" ]]; then
|
||||||
echo "Error: path not found: $target" >> "$LLM_OUTPUT"
|
echo "Error: path not found: $target" >&2
|
||||||
return 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -d "$target" ]]; then
|
if [[ -d "$target" ]]; then
|
||||||
@@ -33,9 +33,20 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local total_lines file_bytes
|
local total_lines file_bytes
|
||||||
total_lines=$(wc -l < "$target" 2>/dev/null || echo 0)
|
# awk counts a final line that lacks a trailing newline; wc -l would undercount it by one.
|
||||||
|
total_lines=$(awk 'END { print NR }' "$target" 2>/dev/null || echo 0)
|
||||||
file_bytes=$(wc -c < "$target" 2>/dev/null || echo 0)
|
file_bytes=$(wc -c < "$target" 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
if [[ "$total_lines" -eq 0 ]]; then
|
||||||
|
echo "(file is empty: $target)" >> "$LLM_OUTPUT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$offset" -gt "$total_lines" ]]; then
|
||||||
|
echo "(offset $offset is past the end of the file, which has $total_lines lines)" >> "$LLM_OUTPUT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$file_bytes" -gt "$MAX_BYTES" ]] && [[ "$offset" -eq 1 ]] && [[ "$limit" -ge 2000 ]]; then
|
if [[ "$file_bytes" -gt "$MAX_BYTES" ]] && [[ "$offset" -eq 1 ]] && [[ "$limit" -ge 2000 ]]; then
|
||||||
{
|
{
|
||||||
echo "Warning: Large file (${file_bytes} bytes, ${total_lines} lines). Showing first ${limit} lines."
|
echo "Warning: Large file (${file_bytes} bytes, ${total_lines} lines). Showing first ${limit} lines."
|
||||||
@@ -48,7 +59,8 @@ main() {
|
|||||||
|
|
||||||
sed -n "${offset},${end_line}p" "$target" 2>/dev/null | {
|
sed -n "${offset},${end_line}p" "$target" 2>/dev/null | {
|
||||||
local line_num=$offset
|
local line_num=$offset
|
||||||
while IFS= read -r line; do
|
# `|| [[ -n "$line" ]]` keeps the final line when the file has no trailing newline.
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
if [[ ${#line} -gt $MAX_LINE_LENGTH ]]; then
|
if [[ ${#line} -gt $MAX_LINE_LENGTH ]]; then
|
||||||
line="${line:0:$MAX_LINE_LENGTH}... (truncated)"
|
line="${line:0:$MAX_LINE_LENGTH}... (truncated)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ patch_file() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line ~ /^@@ /) {
|
if (line ~ /^@@/) {
|
||||||
mode = "hunk"
|
mode = "hunk"
|
||||||
hunkIndex++
|
hunkIndex++
|
||||||
patchLineIndex++
|
patchLineIndex++
|
||||||
@@ -585,6 +585,10 @@ patch_file() {
|
|||||||
|
|
||||||
if (hunkIndex == 0) {
|
if (hunkIndex == 0) {
|
||||||
print "error: no patch" > "/dev/stderr"
|
print "error: no patch" > "/dev/stderr"
|
||||||
|
print "" > "/dev/stderr"
|
||||||
|
print "No hunk header was found. Each hunk must start with a line beginning \"@@\"" > "/dev/stderr"
|
||||||
|
print "(for example \"@@ ... @@\" or \"@@ -1,4 +1,4 @@\"). Inside a hunk, context lines" > "/dev/stderr"
|
||||||
|
print "start with a single space, removed lines with \"-\", and added lines with \"+\"." > "/dev/stderr"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ Additional hard rules:
|
|||||||
- If the evidence points to failing hardware or risk of data loss, stop, say so plainly, and present options before
|
- If the evidence points to failing hardware or risk of data loss, stop, say so plainly, and present options before
|
||||||
touching anything else.
|
touching anything else.
|
||||||
|
|
||||||
|
## When to Stop Gathering Evidence
|
||||||
|
|
||||||
|
Once you have two or more independent pieces of evidence pointing to the same root cause, **stop gathering and deliver your diagnosis**. Do not add more verification steps to verify your verification. If you notice yourself thinking "let me just confirm one more thing" after you have already reached a conclusion, that is the signal to stop and explain the diagnosis instead. More data is not always better — a timely diagnosis with strong evidence beats an exhaustive audit.
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
|
|
||||||
- Lead with what you found, not what you did. Then show the key evidence: the command and the relevant lines of its
|
- Lead with what you found, not what you did. Then show the key evidence: the command and the relevant lines of its
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ security/configuration settings. The analysis aims to ensure a thorough understa
|
|||||||
structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the
|
structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the
|
||||||
potential implementation of best practices.
|
potential implementation of best practices.
|
||||||
|
|
||||||
Should the root directory contain a `COYOTE.md` file, this was generated by Coyote and should be used as a reference
|
Should the root directory contain a `COYOTE.md` (or `AGENTS.md`/`CLAUDE.md`) file, this contains human-curated project
|
||||||
point for all analysis, style questions, etc.
|
instructions and should be used as a reference point for all analysis, style questions, etc.
|
||||||
|
|
||||||
**Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on
|
**Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on
|
||||||
all relevant aspects for understanding and potentially contributing to the project.
|
all relevant aspects for understanding and potentially contributing to the project.
|
||||||
|
|||||||
+48
-106
@@ -5,7 +5,7 @@
|
|||||||
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
|
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
|
||||||
# sbx cp $HOME/.coyote_password testing:/home/agent/
|
# sbx cp $HOME/.coyote_password testing:/home/agent/
|
||||||
# sbx run testing --kit ./sbx-kit/
|
# sbx run testing --kit ./sbx-kit/
|
||||||
schemaVersion: "1"
|
schemaVersion: '1'
|
||||||
kind: sandbox
|
kind: sandbox
|
||||||
name: coyote
|
name: coyote
|
||||||
displayName: Coyote
|
displayName: Coyote
|
||||||
@@ -14,10 +14,10 @@ description: >
|
|||||||
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
||||||
|
|
||||||
sandbox:
|
sandbox:
|
||||||
image: "docker/sandbox-templates:shell-docker"
|
image: 'darkalex17/coyote:v0.7.4'
|
||||||
aiFilename: COYOTE.md
|
aiFilename: COYOTE.md
|
||||||
entrypoint:
|
entrypoint:
|
||||||
run: ["bash", "-lc", "exec /home/agent/.cargo/bin/coyote"]
|
run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
||||||
|
|
||||||
network:
|
network:
|
||||||
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
|
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
|
||||||
@@ -50,96 +50,96 @@ network:
|
|||||||
serviceAuth:
|
serviceAuth:
|
||||||
openai:
|
openai:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
anthropic:
|
anthropic:
|
||||||
headerName: x-api-key
|
headerName: x-api-key
|
||||||
valueFormat: "%s"
|
valueFormat: '%s'
|
||||||
gemini:
|
gemini:
|
||||||
headerName: x-goog-api-key
|
headerName: x-goog-api-key
|
||||||
valueFormat: "%s"
|
valueFormat: '%s'
|
||||||
cohere:
|
cohere:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
groq:
|
groq:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
openrouter:
|
openrouter:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
ai21:
|
ai21:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
cloudflare:
|
cloudflare:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
deepinfra:
|
deepinfra:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
deepseek:
|
deepseek:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
mistral:
|
mistral:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
perplexity:
|
perplexity:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
voyageai:
|
voyageai:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
xai:
|
xai:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
jina:
|
jina:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
ernie:
|
ernie:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
hunyuan:
|
hunyuan:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
minimax:
|
minimax:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
moonshot:
|
moonshot:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
qianwen:
|
qianwen:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
zhipuai:
|
zhipuai:
|
||||||
headerName: Authorization
|
headerName: Authorization
|
||||||
valueFormat: "Bearer %s"
|
valueFormat: 'Bearer %s'
|
||||||
allowedDomains:
|
allowedDomains:
|
||||||
# Coyote release + self-update + model-registry sync
|
# Coyote release + self-update + model-registry sync
|
||||||
- "github.com:443"
|
- 'github.com:443'
|
||||||
- "api.github.com:443"
|
- 'api.github.com:443'
|
||||||
- "raw.githubusercontent.com:443"
|
- 'raw.githubusercontent.com:443'
|
||||||
- "objects.githubusercontent.com:443"
|
- 'objects.githubusercontent.com:443'
|
||||||
- "*.githubusercontent.com:443"
|
- '*.githubusercontent.com:443'
|
||||||
# Coyote install paths (cargo install + uv + rustup + Python tool deps at runtime)
|
# Package managers and developer tools (cargo, uv, pip — useful at runtime for user installs)
|
||||||
- "crates.io:443"
|
- 'crates.io:443'
|
||||||
- "static.crates.io:443"
|
- 'static.crates.io:443'
|
||||||
- "pypi.org:443"
|
- 'pypi.org:443'
|
||||||
- "files.pythonhosted.org:443"
|
- 'files.pythonhosted.org:443'
|
||||||
- "astral.sh:443"
|
- 'astral.sh:443'
|
||||||
- "sh.rustup.rs:443"
|
- 'sh.rustup.rs:443'
|
||||||
- "static.rust-lang.org:443"
|
- 'static.rust-lang.org:443'
|
||||||
|
|
||||||
# LLM model OAuth + API endpoints
|
# LLM model OAuth + API endpoints
|
||||||
- "claude.ai:443"
|
- 'claude.ai:443'
|
||||||
- "console.anthropic.com:443"
|
- 'console.anthropic.com:443'
|
||||||
- "accounts.google.com:443"
|
- 'accounts.google.com:443'
|
||||||
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
||||||
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
||||||
- "*.googleapis.com:443"
|
- '*.googleapis.com:443'
|
||||||
|
|
||||||
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
|
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
|
||||||
# cannot rewrite. Domains are allow-listed; credentials must be injected
|
# cannot rewrite. Domains are allow-listed; credentials must be injected
|
||||||
# separately (see README "Extending").
|
# separately (see README "Extending").
|
||||||
- "*.amazonaws.com:443"
|
- '*.amazonaws.com:443'
|
||||||
- "models.inference.ai.azure.com:443"
|
- 'models.inference.ai.azure.com:443'
|
||||||
|
|
||||||
credentials:
|
credentials:
|
||||||
sources:
|
sources:
|
||||||
@@ -210,9 +210,10 @@ credentials:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
variables:
|
variables:
|
||||||
IS_SANDBOX: "1"
|
IS_SANDBOX: '1'
|
||||||
COYOTE_LOG_LEVEL: INFO
|
COYOTE_LOG_LEVEL: INFO
|
||||||
COYOTE_CONFIG_DIR: /home/agent/.config/coyote
|
COYOTE_CONFIG_DIR: /home/agent/.config/coyote
|
||||||
|
EDITOR: nano
|
||||||
proxyManaged:
|
proxyManaged:
|
||||||
- OPENAI_API_KEY
|
- OPENAI_API_KEY
|
||||||
- ANTHROPIC_API_KEY
|
- ANTHROPIC_API_KEY
|
||||||
@@ -238,73 +239,14 @@ environment:
|
|||||||
- ZHIPUAI_API_KEY
|
- ZHIPUAI_API_KEY
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
install:
|
|
||||||
- command: |
|
|
||||||
sudo apt-get update &&
|
|
||||||
sudo apt-get install -y \
|
|
||||||
jq curl git \
|
|
||||||
build-essential pkg-config \
|
|
||||||
cmake \
|
|
||||||
clang libclang-dev \
|
|
||||||
musl-tools \
|
|
||||||
libssl-dev \
|
|
||||||
pandoc \
|
|
||||||
bzip2
|
|
||||||
user: "1000"
|
|
||||||
description: Install system prerequisites (including pandoc for fetch_url_via_curl)
|
|
||||||
- command: |
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
if [ -f "$HOME/.local/bin/uv" ]; then
|
|
||||||
printf '#!/bin/sh\nexec uv tool run "$@"\n' > "$HOME/.local/bin/uvx"
|
|
||||||
chmod +x "$HOME/.local/bin/uvx"
|
|
||||||
fi
|
|
||||||
user: "1000"
|
|
||||||
description: Install uv and write a uvx shell wrapper (the installer may place a macOS binary at this path on Docker-for-Mac hosts, which the Linux container cannot execute)
|
|
||||||
- command: |
|
|
||||||
set -euo pipefail
|
|
||||||
USQL_VERSION=0.21.4
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
case "$ARCH" in
|
|
||||||
x86_64) USQL_ARCH=amd64 ;;
|
|
||||||
aarch64) USQL_ARCH=arm64 ;;
|
|
||||||
*) echo "Unsupported arch for usql install: $ARCH" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
TMPDIR=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$TMPDIR"' EXIT
|
|
||||||
curl -fsSL --retry 3 "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o "$TMPDIR/usql.tar.bz2"
|
|
||||||
tar -xjf "$TMPDIR/usql.tar.bz2" -C "$TMPDIR"
|
|
||||||
sudo install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql
|
|
||||||
user: "1000"
|
|
||||||
description: Install the usql universal SQL CLI (used by the built-in sql agent and execute_sql_code tool)
|
|
||||||
- command: |
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
|
||||||
sh -s -- -y \
|
|
||||||
--default-toolchain stable \
|
|
||||||
--profile minimal \
|
|
||||||
--target x86_64-unknown-linux-musl
|
|
||||||
. "$HOME/.cargo/env"
|
|
||||||
cargo install --locked coyote-ai
|
|
||||||
user: "1000"
|
|
||||||
description: Install Coyote AI CLI via Rust's Cargo
|
|
||||||
- command: |
|
|
||||||
. "$HOME/.cargo/env"
|
|
||||||
cargo install --locked iwec
|
|
||||||
user: "1000"
|
|
||||||
description: Install the IWE MCP server binary (iwec) used by the built-in iwe MCP server and iwe-knowledge-base skill
|
|
||||||
- command: |
|
|
||||||
. "$HOME/.cargo/env"
|
|
||||||
cargo install --locked ast-grep
|
|
||||||
user: "1000"
|
|
||||||
description: Install ast-grep, used by the built-in ast_grep structural code search tool (and the explore agent)
|
|
||||||
|
|
||||||
startup:
|
startup:
|
||||||
- command:
|
- command:
|
||||||
[
|
[
|
||||||
"sh",
|
'sh',
|
||||||
"-c",
|
'-c',
|
||||||
'test -f "$HOME/.config/coyote/config.yaml" || coyote --info >/dev/null 2>&1 || true',
|
'test -f "$HOME/.config/coyote/config.yaml" || coyote --info >/dev/null 2>&1 || true',
|
||||||
]
|
]
|
||||||
user: "1000"
|
user: '1000'
|
||||||
background: false
|
background: false
|
||||||
description: Bootstrap Coyote config directory on first sandbox start
|
description: Bootstrap Coyote config directory on first sandbox start
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ evidence yourself — never ask the user to run commands and paste output back.
|
|||||||
5. **State each hypothesis in one line before testing it.** Pivot openly when disproved.
|
5. **State each hypothesis in one line before testing it.** Pivot openly when disproved.
|
||||||
6. **Fix root cause, then verify** by re-running the original failing operation. No verification, no fix.
|
6. **Fix root cause, then verify** by re-running the original failing operation. No verification, no fix.
|
||||||
|
|
||||||
|
## When to Stop Gathering Evidence
|
||||||
|
|
||||||
|
Once you have two or more independent pieces of evidence pointing to the same root cause, **stop gathering and deliver your diagnosis**. Do not add more verification steps to verify your verification. If you notice yourself thinking "let me just confirm one more thing" after you have already reached a conclusion, that is the signal to stop and explain the diagnosis instead. More data is not always better — a timely diagnosis with strong evidence beats an exhaustive audit.
|
||||||
|
|
||||||
## Command Discipline
|
## Command Discipline
|
||||||
|
|
||||||
- Non-interactive and bounded, always: `--no-pager`, `-n`/`--since` on logs, `timeout 10` on anything that might
|
- Non-interactive and bounded, always: `--no-pager`, `-n`/`--since` on logs, `timeout 10` on anything that might
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ Use IWE tools when the task involves a corpus of markdown documents: plan reposi
|
|||||||
|
|
||||||
Do NOT use IWE tools for:
|
Do NOT use IWE tools for:
|
||||||
|
|
||||||
- **Agent memory** (`.coyote/memory/`, `COYOTE.md`) — use the `memory__*` tools; they own the index conventions there.
|
- **Agent memory** (`.coyote/memory/`) — use the `memory__*` tools; they own the index conventions there.
|
||||||
|
- **Workspace instructions** (`COYOTE.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`) — human-curated and read-only; never edit them with IWE write tools.
|
||||||
- **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings.
|
- **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings.
|
||||||
- **Source code** — IWE only understands markdown.
|
- **Source code** — IWE only understands markdown.
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,15 @@
|
|||||||
# - <agent-name>_TOP_P
|
# - <agent-name>_TOP_P
|
||||||
# - <agent-name>_GLOBAL_TOOLS (as a JSON string array)
|
# - <agent-name>_GLOBAL_TOOLS (as a JSON string array)
|
||||||
# - <agent-name>_MCP_SERVERS (as a JSON string array)
|
# - <agent-name>_MCP_SERVERS (as a JSON string array)
|
||||||
|
# - <agent-name>_SPAWNABLE_AGENTS (as a JSON string array; see spawnable_agents below)
|
||||||
# - <agent-name>_AGENT_SESSION
|
# - <agent-name>_AGENT_SESSION
|
||||||
# - <agent-name>_VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]')
|
# - <agent-name>_VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]')
|
||||||
|
|
||||||
model: openai:gpt-4o # Specify the LLM to use
|
model: openai:gpt-4o # Specify the LLM to use
|
||||||
temperature: null # Set default temperature parameter, range (0, 1)
|
temperature: null # Set default temperature parameter, range (0, 1)
|
||||||
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
|
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
|
||||||
|
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||||
|
# Only valid when the agent's model declares reasoning_levels.
|
||||||
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
|
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
|
||||||
name: <agent-name> # Name of the agent, used in the UI and logs
|
name: <agent-name> # Name of the agent, used in the UI and logs
|
||||||
description: <description> # Description of the agent, used in the UI
|
description: <description> # Description of the agent, used in the UI
|
||||||
@@ -30,6 +33,12 @@ continuation_prompt: null # Custom prompt used when auto-continuing (opti
|
|||||||
# Enable this agent to spawn and manage child agents in parallel.
|
# Enable this agent to spawn and manage child agents in parallel.
|
||||||
# See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation.
|
# See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation.
|
||||||
can_spawn_agents: false # Enable the agent to spawn child agents
|
can_spawn_agents: false # Enable the agent to spawn child agents
|
||||||
|
# spawnable_agents: # Optional whitelist restricting which agents can be spawned via `agent__spawn`.
|
||||||
|
# - explore # If omitted (the default), ALL installed agents are spawnable. This is the unrestricted default.
|
||||||
|
# - coder # Provide a list to restrict. Match is exact and case-sensitive (use directory names).
|
||||||
|
# - oracle # An empty list ([]) means literally nothing spawnable.
|
||||||
|
# Also filters `agent__list_available` output so the LLM only sees what it can spawn.
|
||||||
|
# Graph agents (graph.yaml) ignore this; they declare spawn targets in agent nodes.
|
||||||
max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously
|
max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously
|
||||||
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning)
|
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning)
|
||||||
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
|
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
|
||||||
|
|||||||
+55
-5
@@ -2,6 +2,8 @@
|
|||||||
model: openai:gpt-4o # Specify the LLM to use
|
model: openai:gpt-4o # Specify the LLM to use
|
||||||
temperature: null # Set default temperature parameter (0, 1)
|
temperature: null # Set default temperature parameter (0, 1)
|
||||||
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
|
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
|
||||||
|
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||||
|
# Only valid when the active model declares reasoning_levels. See the Clients docs.
|
||||||
|
|
||||||
# ---- Behavior ----
|
# ---- Behavior ----
|
||||||
stream: true # Controls whether to use the stream-style APIs when querying for completions from LLM clients.
|
stream: true # Controls whether to use the stream-style APIs when querying for completions from LLM clients.
|
||||||
@@ -18,6 +20,7 @@ agent_session: null # Set a session to use when starting an agent (
|
|||||||
|
|
||||||
# ---- Appearance ----
|
# ---- Appearance ----
|
||||||
highlight: true # Controls syntax highlighting
|
highlight: true # Controls syntax highlighting
|
||||||
|
raw_markdown: false # When true, render markdown as raw text with syntax highlighting only. When false (default), transforms markdown syntax (headings, bold, lists, etc.) into styled terminal output
|
||||||
light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME
|
light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME
|
||||||
|
|
||||||
# ---- Miscellaneous ----
|
# ---- Miscellaneous ----
|
||||||
@@ -31,7 +34,7 @@ sync_models_url: > # URL to sync model changes from
|
|||||||
left_prompt:
|
left_prompt:
|
||||||
'{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '
|
'{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '
|
||||||
right_prompt:
|
right_prompt:
|
||||||
'{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}'
|
'{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}'
|
||||||
|
|
||||||
# ---- Vault ----
|
# ---- Vault ----
|
||||||
# See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault.
|
# See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault.
|
||||||
@@ -134,6 +137,14 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
|
|||||||
# - slack
|
# - slack
|
||||||
# Example (comma-separated form):
|
# Example (comma-separated form):
|
||||||
# enabled_mcp_servers: github,slack,ddg-search
|
# enabled_mcp_servers: github,slack,ddg-search
|
||||||
|
no_workspace_mcp: false # Disable loading workspace-local MCP servers (default: false).
|
||||||
|
# When false (the default), Coyote merges the first workspace MCP config it finds
|
||||||
|
# into the global MCP registry at startup, checking in order:
|
||||||
|
# 1. .coyote/mcp.json
|
||||||
|
# 2. .coyote/.mcp.json (Claude-style file name)
|
||||||
|
# 3. .mcp.json (project root; Claude Code convention)
|
||||||
|
# Workspace entries shadow global ones on name collision.
|
||||||
|
# Set to true (or pass --no-workspace-mcp) to skip this entirely.
|
||||||
|
|
||||||
# ---- Skills ----
|
# ---- Skills ----
|
||||||
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
|
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
|
||||||
@@ -179,8 +190,8 @@ summary_context_prompt: > # The text prompt used for including the summar
|
|||||||
|
|
||||||
# ---- Memory ----
|
# ---- Memory ----
|
||||||
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
|
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
|
||||||
# Memory is opt-in by workspace presence (a `COYOTE.md` or `.coyote/memory/MEMORY.md`)
|
# Memory is opt-in by workspace presence (`.coyote/memory/MEMORY.md`) and global
|
||||||
# and global presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
|
# presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
|
||||||
# even when memory files exist. The cascade is: agent > session > role > app.
|
# even when memory files exist. The cascade is: agent > session > role > app.
|
||||||
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
|
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
|
||||||
# the LLM needs before it will write any memory.
|
# the LLM needs before it will write any memory.
|
||||||
@@ -190,6 +201,18 @@ memory_cap_with_tools: null # Char cap for injected memory when function ca
|
|||||||
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000).
|
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000).
|
||||||
# Indexes plus drill file bodies are injected up to this cap.
|
# Indexes plus drill file bodies are injected up to this cap.
|
||||||
|
|
||||||
|
# ---- Workspace Instructions ----
|
||||||
|
# Human-curated project instructions injected read-only into the system prompt, in full.
|
||||||
|
# Coyote walks up from the current directory and injects the first match from the file
|
||||||
|
# chain below (per directory, in order). Scaffold with `coyote --init-instructions`.
|
||||||
|
# Disable per-invocation with --no-workspace-instructions, or override the chain with
|
||||||
|
# repeatable --workspace-instructions-file flags.
|
||||||
|
workspace_instructions: null # null/true = inject when an instructions file exists; false = never inject
|
||||||
|
workspace_instructions_files: null # File name chain to search, in priority order.
|
||||||
|
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md]
|
||||||
|
# Set to a custom list to reorder or drop fallbacks, e.g.:
|
||||||
|
# workspace_instructions_files: [COYOTE.md]
|
||||||
|
|
||||||
# ---- RAG ----
|
# ---- RAG ----
|
||||||
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
|
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
|
||||||
rag_embedding_model: null # Specifies the embedding model used for context retrieval
|
rag_embedding_model: null # Specifies the embedding model used for context retrieval
|
||||||
@@ -199,7 +222,7 @@ rag_chunk_size: null # Defines the size of chunks for document proce
|
|||||||
rag_chunk_overlap: null # Defines the overlap between chunks
|
rag_chunk_overlap: null # Defines the overlap between chunks
|
||||||
rag_extractor_model: null # LLM model for graph-based entity/relationship extraction; when set, enables a graph RAG signal alongside vector and BM25
|
rag_extractor_model: null # LLM model for graph-based entity/relationship extraction; when set, enables a graph RAG signal alongside vector and BM25
|
||||||
rag_extractor_prompt: null # Custom extraction prompt template; must contain __CHUNK__ placeholder; defaults to built-in prompt when null
|
rag_extractor_prompt: null # Custom extraction prompt template; must contain __CHUNK__ placeholder; defaults to built-in prompt when null
|
||||||
rag_graph_hops: 1 # Number of hops to expand from matched entities at query time (1 = direct neighbors; increase for denser graphs)
|
rag_graph_hops: 1 # Number of hops to expand from matched entities at query time (0 = seed nodes only; 1 = direct neighbors; increase for denser graphs)
|
||||||
# Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__ to tailor searches to specific needs
|
# Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__ to tailor searches to specific needs
|
||||||
rag_template: |
|
rag_template: |
|
||||||
Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
|
Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
|
||||||
@@ -326,11 +349,38 @@ clients:
|
|||||||
api_base: https://api.mistral.ai/v1
|
api_base: https://api.mistral.ai/v1
|
||||||
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||||
|
|
||||||
# See https://docs.x.ai/docs
|
# See https://docs.x.ai/docs - OAuth via SuperGrok / X Premium+ subscription
|
||||||
- type: openai-compatible
|
- type: openai-compatible
|
||||||
name: xai
|
name: xai
|
||||||
api_base: https://api.x.ai/v1
|
api_base: https://api.x.ai/v1
|
||||||
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||||
|
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
|
||||||
|
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||||
|
# Note: Oauth requires SuperGrok/X Premium+ subscription
|
||||||
|
|
||||||
|
# Example: private OpenAI-compatible gateway with client_credentials OAuth
|
||||||
|
# - type: openai-compatible
|
||||||
|
# name: acme-gateway
|
||||||
|
# api_base: https://gateway.acme.com/v1
|
||||||
|
# auth: oauth
|
||||||
|
# oauth:
|
||||||
|
# client_id: '{{ACME_CLIENT_ID}}'
|
||||||
|
# client_secret: '{{ACME_CLIENT_SECRET}}'
|
||||||
|
# token_url: https://auth.acme.com/oauth/token
|
||||||
|
# scopes: [openai.chat]
|
||||||
|
# flow: client_credentials
|
||||||
|
|
||||||
|
# Example: OAuth via Device Authorization Grant (RFC 8628 — for CLIs like Moonshot's kimi-code, MiniMax mmx, etc.)
|
||||||
|
# - type: openai-compatible
|
||||||
|
# name: moonshot
|
||||||
|
# api_base: https://api.kimi.com/coding/v1
|
||||||
|
# auth: oauth
|
||||||
|
# oauth:
|
||||||
|
# client_id: '{{MOONSHOT_CLIENT_ID}}'
|
||||||
|
# device_authorization_url: https://auth.kimi.com/api/oauth/device_authorization
|
||||||
|
# token_url: https://auth.kimi.com/api/oauth/token
|
||||||
|
# flow: device_code
|
||||||
|
# # use_pkce_in_device_flow: true # enable if your provider requires PKCE with device flow
|
||||||
|
|
||||||
# See https://docs.ai21.com/docs/overview
|
# See https://docs.ai21.com/docs/overview
|
||||||
- type: openai-compatible
|
- type: openai-compatible
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ name: <role-name> # The name of the role
|
|||||||
model: openai:gpt-4o # The model to use for this role
|
model: openai:gpt-4o # The model to use for this role
|
||||||
temperature: 0.2 # The temperature to use for this role when querying the model
|
temperature: 0.2 # The temperature to use for this role when querying the model
|
||||||
top_p: 0 # The top_p to use for this role when querying the model
|
top_p: 0 # The top_p to use for this role when querying the model
|
||||||
|
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||||
|
# Only valid when the role's model declares reasoning_levels.
|
||||||
enabled_tools: # Tools to enable for this role. Accepts a YAML list (preferred)
|
enabled_tools: # Tools to enable for this role. Accepts a YAML list (preferred)
|
||||||
- fs_ls # or a comma-separated string (e.g. `enabled_tools: fs_ls,fs_cat`).
|
- fs_ls # or a comma-separated string (e.g. `enabled_tools: fs_ls,fs_cat`).
|
||||||
- fs_cat # Use `all` to enable every visible tool.
|
- fs_cat # Use `all` to enable every visible tool.
|
||||||
|
|||||||
+4
-1
@@ -33,6 +33,8 @@ version: "1.0" # Graph schema version. Only "1.0" is accepte
|
|||||||
model: claude:claude-sonnet-4-6 # Default model for `llm` nodes that don't override it
|
model: claude:claude-sonnet-4-6 # Default model for `llm` nodes that don't override it
|
||||||
temperature: 0.0 # Default sampling temperature for `llm` nodes
|
temperature: 0.0 # Default sampling temperature for `llm` nodes
|
||||||
top_p: null # Default sampling top-p for `llm` nodes
|
top_p: null # Default sampling top-p for `llm` nodes
|
||||||
|
reasoning_effort: null # Default reasoning effort for `llm` nodes that don't override it.
|
||||||
|
# Only valid when the model declares reasoning_levels.
|
||||||
|
|
||||||
global_tools: # Tool universe an `llm` node's `tools:` whitelist draws from
|
global_tools: # Tool universe an `llm` node's `tools:` whitelist draws from
|
||||||
- web_search_coyote.sh
|
- web_search_coyote.sh
|
||||||
@@ -227,7 +229,7 @@ nodes:
|
|||||||
reranker_model: null # Optional reranker for hybrid-search results
|
reranker_model: null # Optional reranker for hybrid-search results
|
||||||
extractor_model: null # Optional chat model for graph-based entity/relationship extraction; enables graph RAG signal when set
|
extractor_model: null # Optional chat model for graph-based entity/relationship extraction; enables graph RAG signal when set
|
||||||
extractor_prompt: null # Optional custom extraction prompt; must contain __CHUNK__ placeholder; uses built-in prompt when null
|
extractor_prompt: null # Optional custom extraction prompt; must contain __CHUNK__ placeholder; uses built-in prompt when null
|
||||||
graph_hops: 1 # Graph expansion depth at query time (1 = direct neighbors; increase for denser knowledge graphs)
|
graph_hops: 1 # Graph expansion depth at query time (0 = seed nodes only; 1 = direct neighbors; increase for denser knowledge graphs)
|
||||||
batch_size: 100 # Optional embedding-request batch size
|
batch_size: 100 # Optional embedding-request batch size
|
||||||
state_updates: # {{output}} = { context: <str>, sources: [<path>, ...] }
|
state_updates: # {{output}} = { context: <str>, sources: [<path>, ...] }
|
||||||
context: "{{output.context}}" # writes `context` -> `reducers.context = concat`
|
context: "{{output.context}}" # writes `context` -> `reducers.context = concat`
|
||||||
@@ -394,6 +396,7 @@ nodes:
|
|||||||
- mcp:ddg-search # `mcp:<server>` includes that server's functions
|
- mcp:ddg-search # `mcp:<server>` includes that server's functions
|
||||||
model: claude:claude-haiku-4-5 # Optional per-node model override
|
model: claude:claude-haiku-4-5 # Optional per-node model override
|
||||||
temperature: 0.3 # Optional per-node sampling override
|
temperature: 0.3 # Optional per-node sampling override
|
||||||
|
reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high)
|
||||||
max_attempts: 2 # Retry count on transient errors only. Default 1.
|
max_attempts: 2 # Retry count on transient errors only. Default 1.
|
||||||
max_iterations: 10 # Tool-call-loop turn cap. Default 10.
|
max_iterations: 10 # Tool-call-loop turn cap. Default 10.
|
||||||
fallback: review # Route here if all attempts fail
|
fallback: review # Route here if all attempts fail
|
||||||
|
|||||||
@@ -23,3 +23,16 @@ fmt:
|
|||||||
[arg('build_type', pattern="debug|release")]
|
[arg('build_type', pattern="debug|release")]
|
||||||
build build_type='debug':
|
build build_type='debug':
|
||||||
@cargo build {{ if build_type == "release" { "--release" } else { "" } }}
|
@cargo build {{ if build_type == "release" { "--release" } else { "" } }}
|
||||||
|
|
||||||
|
# Build a multi-platform Docker image (linux/amd64 + linux/arm64).
|
||||||
|
# Requires an active buildx builder with multi-platform support and a registry login.
|
||||||
|
# version: must match an existing GitHub release tag (e.g. 0.7.4)
|
||||||
|
# image: registry/image name to push to (default: darkalex17/coyote)
|
||||||
|
[group: 'build']
|
||||||
|
docker-build version image='darkalex17/coyote':
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--build-arg COYOTE_VERSION={{ version }} \
|
||||||
|
--tag {{ image }}:{{ version }} \
|
||||||
|
--tag {{ image }}:latest \
|
||||||
|
.
|
||||||
|
|||||||
+348
-2
@@ -3,6 +3,33 @@
|
|||||||
# - https://platform.openai.com/docs/api-reference/chat
|
# - https://platform.openai.com/docs/api-reference/chat
|
||||||
- provider: openai
|
- provider: openai
|
||||||
models:
|
models:
|
||||||
|
- name: gpt-5.6-sol
|
||||||
|
max_input_tokens: 1050000
|
||||||
|
max_output_tokens: 128000
|
||||||
|
input_price: 5
|
||||||
|
output_price: 30
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: medium
|
||||||
|
- name: gpt-5.6-terra
|
||||||
|
max_input_tokens: 1050000
|
||||||
|
max_output_tokens: 128000
|
||||||
|
input_price: 5
|
||||||
|
output_price: 30
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: medium
|
||||||
|
- name: gpt-5.6-luna
|
||||||
|
max_input_tokens: 1050000
|
||||||
|
max_output_tokens: 128000
|
||||||
|
input_price: 5
|
||||||
|
output_price: 30
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gpt-5.5
|
- name: gpt-5.5
|
||||||
max_input_tokens: 1050000
|
max_input_tokens: 1050000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -10,6 +37,8 @@
|
|||||||
output_price: 30
|
output_price: 30
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gpt-5.5-pro
|
- name: gpt-5.5-pro
|
||||||
max_input_tokens: 1050000
|
max_input_tokens: 1050000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -17,6 +46,8 @@
|
|||||||
output_price: 180
|
output_price: 180
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [medium, high, xhigh]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: gpt-5.4
|
- name: gpt-5.4
|
||||||
max_input_tokens: 1050000
|
max_input_tokens: 1050000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -24,6 +55,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: none
|
||||||
- name: gpt-5.4-pro
|
- name: gpt-5.4-pro
|
||||||
max_input_tokens: 1050000
|
max_input_tokens: 1050000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -31,6 +64,8 @@
|
|||||||
output_price: 180
|
output_price: 180
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [medium, high, xhigh]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gpt-5.4-mini
|
- name: gpt-5.4-mini
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -38,6 +73,8 @@
|
|||||||
output_price: 4.5
|
output_price: 4.5
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: none
|
||||||
- name: gpt-5.4-nano
|
- name: gpt-5.4-nano
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -45,6 +82,8 @@
|
|||||||
output_price: 1.25
|
output_price: 1.25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: none
|
||||||
- name: gpt-5.3-codex
|
- name: gpt-5.3-codex
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -52,6 +91,8 @@
|
|||||||
output_price: 14
|
output_price: 14
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: chat-latest
|
- name: chat-latest
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -66,6 +107,17 @@
|
|||||||
output_price: 14
|
output_price: 14
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: none
|
||||||
|
- name: gpt-5.2-pro
|
||||||
|
max_input_tokens: 400000
|
||||||
|
max_output_tokens: 128000
|
||||||
|
input_price: 21
|
||||||
|
output_price: 168
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [medium, high, xhigh]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gpt-5.1
|
- name: gpt-5.1
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -73,6 +125,8 @@
|
|||||||
output_price: 10
|
output_price: 10
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high]
|
||||||
|
default_reasoning_effort: none
|
||||||
- name: gpt-5.1-chat-latest
|
- name: gpt-5.1-chat-latest
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -80,6 +134,8 @@
|
|||||||
output_price: 10
|
output_price: 10
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high]
|
||||||
|
default_reasoning_effort: none
|
||||||
- name: gpt-5
|
- name: gpt-5
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -87,6 +143,8 @@
|
|||||||
output_price: 10
|
output_price: 10
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gpt-5-chat-latest
|
- name: gpt-5-chat-latest
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -94,6 +152,8 @@
|
|||||||
output_price: 10
|
output_price: 10
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gpt-5-mini
|
- name: gpt-5-mini
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -151,6 +211,8 @@
|
|||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
system_prompt_prefix: Formatting re-enabled
|
system_prompt_prefix: Formatting re-enabled
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: medium
|
||||||
patch:
|
patch:
|
||||||
body:
|
body:
|
||||||
max_tokens: null
|
max_tokens: null
|
||||||
@@ -258,24 +320,38 @@
|
|||||||
# - https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent
|
# - https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent
|
||||||
- provider: gemini
|
- provider: gemini
|
||||||
models:
|
models:
|
||||||
|
- name: gemini-3.6-flash
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
max_output_tokens: 65536
|
||||||
|
input_price: 1.5
|
||||||
|
output_price: 7.5
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_level: medium
|
||||||
- name: gemini-3.5-flash
|
- name: gemini-3.5-flash
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
input_price: 0.2
|
input_price: 0.2
|
||||||
output_price: 1.5
|
output_price: 1.5
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gemini-3-flash-preview
|
- name: gemini-3-flash-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
input_price: 0.2
|
input_price: 0.2
|
||||||
output_price: 1.5
|
output_price: 1.5
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: gemini-3.1-flash-lite
|
- name: gemini-3.1-flash-lite
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
input_price: 0.2
|
input_price: 0.2
|
||||||
output_price: 1.5
|
output_price: 1.5
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: minimal
|
||||||
- name: gemini-3.1-pro-preview
|
- name: gemini-3.1-pro-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65535
|
max_output_tokens: 65535
|
||||||
@@ -283,6 +359,8 @@
|
|||||||
output_price: 2.5
|
output_price: 2.5
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: gemini-2.5-flash
|
- name: gemini-2.5-flash
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
@@ -297,6 +375,8 @@
|
|||||||
output_price: 0
|
output_price: 0
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: gemini-2.5-flash-lite
|
- name: gemini-2.5-flash-lite
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 64000
|
max_output_tokens: 64000
|
||||||
@@ -308,10 +388,14 @@
|
|||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, high]
|
||||||
|
default_reasoning_level: high
|
||||||
- name: gemini-3-flash-preview
|
- name: gemini-3-flash-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_level: high
|
||||||
- name: gemma-3-27b-it
|
- name: gemma-3-27b-it
|
||||||
max_input_tokens: 131072
|
max_input_tokens: 131072
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -337,6 +421,8 @@
|
|||||||
output_price: 50
|
output_price: 50
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-opus-4-8
|
- name: claude-opus-4-8
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -345,6 +431,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-opus-4-7
|
- name: claude-opus-4-7
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -353,6 +441,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-opus-4-6
|
- name: claude-opus-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -361,6 +451,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-opus-4-6:thinking
|
- name: claude-opus-4-6:thinking
|
||||||
real_name: claude-opus-4-6
|
real_name: claude-opus-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
@@ -385,6 +477,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-sonnet-4-6
|
- name: claude-sonnet-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -393,6 +487,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-sonnet-4-6:thinking
|
- name: claude-sonnet-4-6:thinking
|
||||||
real_name: claude-sonnet-4-6
|
real_name: claude-sonnet-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
@@ -716,14 +812,65 @@
|
|||||||
# - https://docs.x.ai/docs/models
|
# - https://docs.x.ai/docs/models
|
||||||
# - https://docs.x.ai/docs/api-reference#chat-completions
|
# - https://docs.x.ai/docs/api-reference#chat-completions
|
||||||
- provider: xai
|
- provider: xai
|
||||||
|
oauth:
|
||||||
|
client_id: b1a00492-073a-47ea-816f-4c329264a828
|
||||||
|
authorize_url: https://auth.x.ai/oauth2/authorize
|
||||||
|
token_url: https://auth.x.ai/oauth2/token
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- profile
|
||||||
|
- email
|
||||||
|
- offline_access
|
||||||
|
- grok-cli:access
|
||||||
|
- api:access
|
||||||
|
redirect_port: 56121
|
||||||
|
flow: pkce
|
||||||
|
token_request_format: form_url_encoded
|
||||||
|
extra_authorize_params:
|
||||||
|
plan: generic
|
||||||
|
referrer: coyote
|
||||||
|
echo_pkce_in_token_exchange: true
|
||||||
models:
|
models:
|
||||||
|
- name: grok-4.5
|
||||||
|
input_price: 2
|
||||||
|
output_price: 6
|
||||||
|
max_input_tokens: 256000
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: grok-build-0.1
|
||||||
|
input_price: 1
|
||||||
|
output_price: 2
|
||||||
|
max_input_tokens: 256000
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: grok-4.3
|
||||||
|
input_price: 1.25
|
||||||
|
output_price: 2.5
|
||||||
|
max_input_tokens: 1000000
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: grok-4.20
|
||||||
|
real_name: grok-4.20-multi-agent-0309
|
||||||
|
input_price: 1.25
|
||||||
|
output_price: 2.5
|
||||||
|
max_input_tokens: 1000000
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: grok-4.20-reasoning
|
||||||
|
real_name: grok-4.20-0309-reasoning
|
||||||
|
input_price: 1.25
|
||||||
|
output_price: 2.5
|
||||||
|
max_input_tokens: 1000000
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: grok-4.20-non-reasoning
|
||||||
|
real_name: grok-4.20-0309-non-reasoning
|
||||||
|
input_price: 1.25
|
||||||
|
output_price: 2.5
|
||||||
|
max_input_tokens: 1000000
|
||||||
|
supports_function_calling: true
|
||||||
- name: grok-4-1-fast-non-reasoning
|
- name: grok-4-1-fast-non-reasoning
|
||||||
max_input_tokens: 2000000
|
max_input_tokens: 1000000
|
||||||
input_price: 0.2
|
input_price: 0.2
|
||||||
output_price: 0.5
|
output_price: 0.5
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
- name: grok-4-1-fast-reasoning
|
- name: grok-4-1-fast-reasoning
|
||||||
max_input_tokens: 2000000
|
max_input_tokens: 1000000
|
||||||
input_price: 0.2
|
input_price: 0.2
|
||||||
output_price: 0.5
|
output_price: 0.5
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
@@ -835,18 +982,24 @@
|
|||||||
input_price: 0.2
|
input_price: 0.2
|
||||||
output_price: 1.5
|
output_price: 1.5
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gemini-3-flash-preview
|
- name: gemini-3-flash-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
input_price: 0.2
|
input_price: 0.2
|
||||||
output_price: 1.5
|
output_price: 1.5
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: gemini-3.1-flash-lite
|
- name: gemini-3.1-flash-lite
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
input_price: 0.2
|
input_price: 0.2
|
||||||
output_price: 1.5
|
output_price: 1.5
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, high]
|
||||||
|
default_reasoning_effort: minimal
|
||||||
- name: gemini-3.1-pro-preview
|
- name: gemini-3.1-pro-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
@@ -854,6 +1007,8 @@
|
|||||||
output_price: 12
|
output_price: 12
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: gemini-2.5-flash
|
- name: gemini-2.5-flash
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65535
|
max_output_tokens: 65535
|
||||||
@@ -861,6 +1016,8 @@
|
|||||||
output_price: 2.5
|
output_price: 2.5
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: gemini-2.5-pro
|
- name: gemini-2.5-pro
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
@@ -868,6 +1025,8 @@
|
|||||||
output_price: 10
|
output_price: 10
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: gemini-2.5-flash-lite
|
- name: gemini-2.5-flash-lite
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
max_output_tokens: 65536
|
max_output_tokens: 65536
|
||||||
@@ -879,10 +1038,14 @@
|
|||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: gemini-3-flash-preview
|
- name: gemini-3-flash-preview
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-fable-5
|
- name: claude-fable-5
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -891,6 +1054,8 @@
|
|||||||
output_price: 50
|
output_price: 50
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-opus-4-8
|
- name: claude-opus-4-8
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -899,6 +1064,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-opus-4-7
|
- name: claude-opus-4-7
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -907,6 +1074,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-opus-4-6
|
- name: claude-opus-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -938,6 +1107,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-sonnet-4-6
|
- name: claude-sonnet-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -946,6 +1117,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: claude-sonnet-4-6:thinking
|
- name: claude-sonnet-4-6:thinking
|
||||||
real_name: claude-sonnet-4-6
|
real_name: claude-sonnet-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
@@ -1078,6 +1251,8 @@
|
|||||||
output_price: 50
|
output_price: 50
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: us.anthropic.claude-opus-4-8
|
- name: us.anthropic.claude-opus-4-8
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1086,6 +1261,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: us.anthropic.claude-opus-4-7
|
- name: us.anthropic.claude-opus-4-7
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1094,6 +1271,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: us.anthropic.claude-opus-4-6-v1
|
- name: us.anthropic.claude-opus-4-6-v1
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -1102,6 +1281,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: us.anthropic.claude-opus-4-6-v1:thinking
|
- name: us.anthropic.claude-opus-4-6-v1:thinking
|
||||||
real_name: us.anthropic.claude-opus-4-6-v1
|
real_name: us.anthropic.claude-opus-4-6-v1
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
@@ -1127,6 +1308,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: us.anthropic.claude-sonnet-4-6
|
- name: us.anthropic.claude-sonnet-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -1135,6 +1318,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: us.anthropic.claude-sonnet-4-6:thinking
|
- name: us.anthropic.claude-sonnet-4-6:thinking
|
||||||
real_name: us.anthropic.claude-sonnet-4-6
|
real_name: us.anthropic.claude-sonnet-4-6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
@@ -1516,6 +1701,30 @@
|
|||||||
# - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80
|
# - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80
|
||||||
- provider: moonshot
|
- provider: moonshot
|
||||||
models:
|
models:
|
||||||
|
- name: kimi-k3
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
input_price: 3
|
||||||
|
output_price: 15
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: kimi-k2.7-code
|
||||||
|
max_input_tokens: 262144
|
||||||
|
input_price: 0.95
|
||||||
|
output_price: 4
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: kimi-k2.7-code-highspeed
|
||||||
|
max_input_tokens: 262144
|
||||||
|
input_price: 1.9
|
||||||
|
output_price: 8
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: kimi-k2.6
|
||||||
|
max_input_tokens: 262144
|
||||||
|
input_price: 0.95
|
||||||
|
output_price: 4
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
- name: kimi-k2.5
|
- name: kimi-k2.5
|
||||||
max_input_tokens: 262144
|
max_input_tokens: 262144
|
||||||
input_price: 0.56
|
input_price: 0.56
|
||||||
@@ -1550,6 +1759,18 @@
|
|||||||
# - https://platform.deepseek.com/api-docs/api/create-chat-completion
|
# - https://platform.deepseek.com/api-docs/api/create-chat-completion
|
||||||
- provider: deepseek
|
- provider: deepseek
|
||||||
models:
|
models:
|
||||||
|
- name: deepseek-v4-pro
|
||||||
|
max_input_tokens: 1000000
|
||||||
|
max_output_tokens: 384000
|
||||||
|
input_price: 0.435
|
||||||
|
output_price: 0.87
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: deepseek-v4-flash
|
||||||
|
max_input_tokens: 1000000
|
||||||
|
max_output_tokens: 384000
|
||||||
|
input_price: 0.14
|
||||||
|
output_price: 0.28
|
||||||
|
supports_function_calling: true
|
||||||
- name: deepseek-chat
|
- name: deepseek-chat
|
||||||
max_input_tokens: 64000
|
max_input_tokens: 64000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -1618,6 +1839,16 @@
|
|||||||
# - https://platform.minimaxi.com/document/ChatCompletion%20v2
|
# - https://platform.minimaxi.com/document/ChatCompletion%20v2
|
||||||
- provider: minimax
|
- provider: minimax
|
||||||
models:
|
models:
|
||||||
|
- name: minimax-m3
|
||||||
|
max_input_tokens: 1000000
|
||||||
|
input_price: 4.2
|
||||||
|
output_price: 16.8
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: minimax-m2.7
|
||||||
|
max_input_tokens: 204800
|
||||||
|
input_price: 0.294
|
||||||
|
output_price: 1.176
|
||||||
|
supports_function_calling: true
|
||||||
- name: minimax-m2.5
|
- name: minimax-m2.5
|
||||||
max_input_tokens: 204800
|
max_input_tokens: 204800
|
||||||
input_price: 0.294
|
input_price: 0.294
|
||||||
@@ -1644,6 +1875,33 @@
|
|||||||
# - https://openrouter.ai/docs/api-reference/chat-completion
|
# - https://openrouter.ai/docs/api-reference/chat-completion
|
||||||
- provider: openrouter
|
- provider: openrouter
|
||||||
models:
|
models:
|
||||||
|
- name: openai/gpt-5.6-sol
|
||||||
|
max_input_tokens: 1050000
|
||||||
|
max_output_tokens: 128000
|
||||||
|
input_price: 5
|
||||||
|
output_price: 30
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: medium
|
||||||
|
- name: openai/gpt-5.6-terra
|
||||||
|
max_input_tokens: 1050000
|
||||||
|
max_output_tokens: 128000
|
||||||
|
input_price: 5
|
||||||
|
output_price: 30
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: medium
|
||||||
|
- name: openai/gpt-5.6-luna
|
||||||
|
max_input_tokens: 1050000
|
||||||
|
max_output_tokens: 128000
|
||||||
|
input_price: 5
|
||||||
|
output_price: 30
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: openai/gpt-5.5
|
- name: openai/gpt-5.5
|
||||||
max_input_tokens: 1050000
|
max_input_tokens: 1050000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1651,6 +1909,8 @@
|
|||||||
output_price: 30
|
output_price: 30
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: openai/gpt-5.5-pro
|
- name: openai/gpt-5.5-pro
|
||||||
max_input_tokens: 1050000
|
max_input_tokens: 1050000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1658,6 +1918,8 @@
|
|||||||
output_price: 180
|
output_price: 180
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [medium, high, xhigh]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: openai/gpt-5.4
|
- name: openai/gpt-5.4
|
||||||
max_input_tokens: 1050000
|
max_input_tokens: 1050000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1665,6 +1927,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: none
|
||||||
- name: openai/gpt-5.4-pro
|
- name: openai/gpt-5.4-pro
|
||||||
max_input_tokens: 1050000
|
max_input_tokens: 1050000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1672,6 +1936,8 @@
|
|||||||
output_price: 180
|
output_price: 180
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [medium, high, xhigh]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: openai/gpt-5.4-mini
|
- name: openai/gpt-5.4-mini
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1679,6 +1945,8 @@
|
|||||||
output_price: 4.5
|
output_price: 4.5
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: none
|
||||||
- name: openai/gpt-5.4-nano
|
- name: openai/gpt-5.4-nano
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1686,6 +1954,8 @@
|
|||||||
output_price: 1.25
|
output_price: 1.25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: none
|
||||||
- name: openai/gpt-5.3-codex
|
- name: openai/gpt-5.3-codex
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1693,6 +1963,8 @@
|
|||||||
output_price: 14
|
output_price: 14
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: openai/gpt-5.2
|
- name: openai/gpt-5.2
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1700,6 +1972,17 @@
|
|||||||
output_price: 14
|
output_price: 14
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [none, low, medium, high, xhigh]
|
||||||
|
default_reasoning_effort: none
|
||||||
|
- name: openai/gpt-5.2-pro
|
||||||
|
max_input_tokens: 400000
|
||||||
|
max_output_tokens: 128000
|
||||||
|
input_price: 21
|
||||||
|
output_price: 168
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [medium, high, xhigh]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: openai/gpt-5
|
- name: openai/gpt-5
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1707,6 +1990,8 @@
|
|||||||
output_price: 10
|
output_price: 10
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: medium
|
||||||
- name: openai/gpt-5-mini
|
- name: openai/gpt-5-mini
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1744,18 +2029,67 @@
|
|||||||
input_price: 0.04
|
input_price: 0.04
|
||||||
output_price: 0.16
|
output_price: 0.16
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
- name: google/gemini-3.5-flash
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
max_output_tokens: 65536
|
||||||
|
input_price: 0.2
|
||||||
|
output_price: 1.5
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: medium
|
||||||
|
- name: google/gemini-3-flash-preview
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
max_output_tokens: 65536
|
||||||
|
input_price: 0.2
|
||||||
|
output_price: 1.5
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
|
- name: google/gemini-3.1-flash-lite
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
max_output_tokens: 65536
|
||||||
|
input_price: 0.2
|
||||||
|
output_price: 1.5
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_effort: minimal
|
||||||
|
- name: google/gemini-3.1-pro-preview
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
max_output_tokens: 65535
|
||||||
|
input_price: 0.3
|
||||||
|
output_price: 2.5
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
|
- name: google/gemini-3-pro-preview
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, high]
|
||||||
|
default_reasoning_level: high
|
||||||
|
- name: google/gemini-3-flash-preview
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [minimal, low, medium, high]
|
||||||
|
default_reasoning_level: high
|
||||||
- name: google/gemini-2.5-flash
|
- name: google/gemini-2.5-flash
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
input_price: 0.3
|
input_price: 0.3
|
||||||
output_price: 2.5
|
output_price: 2.5
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: low
|
||||||
- name: google/gemini-2.5-pro
|
- name: google/gemini-2.5-pro
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
input_price: 1.25
|
input_price: 1.25
|
||||||
output_price: 10
|
output_price: 10
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: google/gemini-2.5-flash-lite
|
- name: google/gemini-2.5-flash-lite
|
||||||
max_input_tokens: 1048576
|
max_input_tokens: 1048576
|
||||||
input_price: 0.3
|
input_price: 0.3
|
||||||
@@ -1785,6 +2119,8 @@
|
|||||||
output_price: 50
|
output_price: 50
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: anthropic/claude-opus-4-8
|
- name: anthropic/claude-opus-4-8
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1793,6 +2129,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: anthropic/claude-opus-4-7
|
- name: anthropic/claude-opus-4-7
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1801,6 +2139,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: anthropic/claude-opus-4.6
|
- name: anthropic/claude-opus-4.6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -1809,6 +2149,8 @@
|
|||||||
output_price: 25
|
output_price: 25
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: anthropic/claude-sonnet-5
|
- name: anthropic/claude-sonnet-5
|
||||||
max_input_tokens: 1000000
|
max_input_tokens: 1000000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1817,6 +2159,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, xhigh, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: anthropic/claude-sonnet-4.6
|
- name: anthropic/claude-sonnet-4.6
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
@@ -1825,6 +2169,8 @@
|
|||||||
output_price: 15
|
output_price: 15
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_function_calling: true
|
supports_function_calling: true
|
||||||
|
reasoning_levels: [low, medium, high, max]
|
||||||
|
default_reasoning_effort: high
|
||||||
- name: anthropic/claude-opus-4.5
|
- name: anthropic/claude-opus-4.5
|
||||||
max_input_tokens: 200000
|
max_input_tokens: 200000
|
||||||
max_output_tokens: 8192
|
max_output_tokens: 8192
|
||||||
|
|||||||
+164
-109
@@ -43,6 +43,10 @@ use std::io::{Read, stdin};
|
|||||||
),
|
),
|
||||||
)]
|
)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
|
/// Input text
|
||||||
|
#[arg(trailing_var_arg = true)]
|
||||||
|
text: Vec<String>,
|
||||||
|
|
||||||
/// Select a LLM model
|
/// Select a LLM model
|
||||||
#[arg(short, long, add = ArgValueCompleter::new(model_completer))]
|
#[arg(short, long, add = ArgValueCompleter::new(model_completer))]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
@@ -52,30 +56,6 @@ pub struct Cli {
|
|||||||
/// Select a role
|
/// Select a role
|
||||||
#[arg(short, long, add = ArgValueCompleter::new(role_completer))]
|
#[arg(short, long, add = ArgValueCompleter::new(role_completer))]
|
||||||
pub role: Option<String>,
|
pub role: Option<String>,
|
||||||
/// Start or join a session
|
|
||||||
#[arg(short = 's', long, add = ArgValueCompleter::new(session_completer))]
|
|
||||||
pub session: Option<Option<String>>,
|
|
||||||
/// Ensure the session is empty
|
|
||||||
#[arg(long)]
|
|
||||||
pub empty_session: bool,
|
|
||||||
/// Ensure the new conversation is saved to the session
|
|
||||||
#[arg(long)]
|
|
||||||
pub save_session: bool,
|
|
||||||
/// Start an agent
|
|
||||||
#[arg(short = 'a', long, add = ArgValueCompleter::new(agent_completer))]
|
|
||||||
pub agent: Option<String>,
|
|
||||||
/// Set agent variables
|
|
||||||
#[arg(long, value_names = ["NAME", "VALUE"], num_args = 2)]
|
|
||||||
pub agent_variable: Vec<String>,
|
|
||||||
/// Start a RAG
|
|
||||||
#[arg(long, add = ArgValueCompleter::new(rag_completer))]
|
|
||||||
pub rag: Option<String>,
|
|
||||||
/// Rebuild the RAG to sync document changes
|
|
||||||
#[arg(long)]
|
|
||||||
pub rebuild_rag: bool,
|
|
||||||
/// Execute a macro
|
|
||||||
#[arg(long = "macro", value_name = "MACRO", add = ArgValueCompleter::new(macro_completer))]
|
|
||||||
pub macro_name: Option<String>,
|
|
||||||
/// Execute commands in natural language
|
/// Execute commands in natural language
|
||||||
#[arg(short = 'e', long)]
|
#[arg(short = 'e', long)]
|
||||||
pub execute: bool,
|
pub execute: bool,
|
||||||
@@ -88,113 +68,188 @@ pub struct Cli {
|
|||||||
/// Turn off stream mode
|
/// Turn off stream mode
|
||||||
#[arg(short = 'S', long)]
|
#[arg(short = 'S', long)]
|
||||||
pub no_stream: bool,
|
pub no_stream: bool,
|
||||||
/// Disable memory for this invocation
|
/// Render markdown as raw text with syntax highlighting only (skip the rich markdown renderer)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub no_memory: bool,
|
pub raw_markdown: bool,
|
||||||
/// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!)
|
|
||||||
#[arg(long)]
|
|
||||||
pub dangerously_skip_permissions: bool,
|
|
||||||
/// Bootstrap a memory marker so coyote begins loading memory next run
|
|
||||||
#[arg(long, value_name = "SCOPE", value_enum)]
|
|
||||||
pub init_memory: Option<MemoryScope>,
|
|
||||||
/// Display the message without sending it
|
/// Display the message without sending it
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
/// Display information
|
/// Disable loading workspace MCP servers from .coyote/mcp.json, .coyote/.mcp.json, or .mcp.json
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub info: bool,
|
pub no_workspace_mcp: bool,
|
||||||
/// Build all configured Bash tool scripts
|
/// Disable memory for this invocation
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub build_tools: bool,
|
pub no_memory: bool,
|
||||||
/// Reinstall bundled assets, overwriting any local changes
|
/// Disable loading workspace instructions (COYOTE.md/AGENTS.md/CLAUDE.md/etc.) for this invocation
|
||||||
#[arg(long, value_name = "CATEGORY", value_enum)]
|
|
||||||
pub install: Option<AssetCategory>,
|
|
||||||
/// Install assets from a remote git repository (URL may be suffixed with #<ref>)
|
|
||||||
#[arg(long, value_name = "GIT_URL")]
|
|
||||||
pub install_from: Option<String>,
|
|
||||||
/// Restrict --install-from to a single asset category
|
|
||||||
#[arg(long, value_name = "CATEGORY", value_enum, requires = "install_from")]
|
|
||||||
pub filter: Option<InstallFilter>,
|
|
||||||
/// Overwrite all conflicts without prompting (used with --install-from)
|
|
||||||
#[arg(long, requires = "install_from")]
|
|
||||||
pub install_force: bool,
|
|
||||||
/// Sync models updates
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub sync_models: bool,
|
pub no_workspace_instructions: bool,
|
||||||
/// List all available chat models
|
/// Override the workspace instructions file chain for this invocation (repeatable, priority order)
|
||||||
|
#[arg(long, value_name = "NAME")]
|
||||||
|
pub workspace_instructions_file: Vec<String>,
|
||||||
|
/// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub list_models: bool,
|
pub dangerously_skip_permissions: bool,
|
||||||
/// List all roles
|
|
||||||
#[arg(long)]
|
/// Start or join a session
|
||||||
pub list_roles: bool,
|
#[arg(short = 's', long, help_heading = "Session & Memory", add = ArgValueCompleter::new(session_completer))]
|
||||||
/// List all sessions
|
pub session: Option<Option<String>>,
|
||||||
#[arg(long)]
|
/// Ensure the session is empty
|
||||||
pub list_sessions: bool,
|
#[arg(long, help_heading = "Session & Memory")]
|
||||||
/// List all agents
|
pub empty_session: bool,
|
||||||
#[arg(long)]
|
/// Ensure the new conversation is saved to the session
|
||||||
pub list_agents: bool,
|
#[arg(long, help_heading = "Session & Memory")]
|
||||||
/// List all RAGs
|
pub save_session: bool,
|
||||||
#[arg(long)]
|
/// Bootstrap a memory marker so coyote begins loading memory next run
|
||||||
pub list_rags: bool,
|
#[arg(
|
||||||
/// List all macros
|
long,
|
||||||
#[arg(long)]
|
value_name = "SCOPE",
|
||||||
pub list_macros: bool,
|
value_enum,
|
||||||
/// List all installed skills
|
help_heading = "Session & Memory"
|
||||||
#[arg(long)]
|
)]
|
||||||
pub list_skills: bool,
|
pub init_memory: Option<MemoryScope>,
|
||||||
|
/// Scaffold a COYOTE.md workspace instructions file in the current directory
|
||||||
|
#[arg(long, help_heading = "Session & Memory")]
|
||||||
|
pub init_instructions: bool,
|
||||||
/// Pre-load an existing skill into the session (repeatable). If a single
|
/// Pre-load an existing skill into the session (repeatable). If a single
|
||||||
/// `--skill <NAME>` is given and the skill doesn't exist, opens $EDITOR
|
/// `--skill <NAME>` is given and the skill doesn't exist, opens $EDITOR
|
||||||
/// with a scaffold to create it.
|
/// with a scaffold to create it.
|
||||||
#[arg(long, value_name = "NAME")]
|
#[arg(long, value_name = "NAME", help_heading = "Session & Memory")]
|
||||||
pub skill: Vec<String>,
|
pub skill: Vec<String>,
|
||||||
/// Input text
|
|
||||||
#[arg(trailing_var_arg = true)]
|
/// Start an agent
|
||||||
text: Vec<String>,
|
#[arg(short = 'a', long, help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(agent_completer))]
|
||||||
/// Tail logs
|
pub agent: Option<String>,
|
||||||
#[arg(long)]
|
/// Set agent variables
|
||||||
pub tail_logs: bool,
|
#[arg(long, value_names = ["NAME", "VALUE"], num_args = 2, help_heading = "Agents, RAG & Macros")]
|
||||||
/// Disable colored log output
|
pub agent_variable: Vec<String>,
|
||||||
#[arg(long, requires = "tail_logs")]
|
/// Start a RAG
|
||||||
pub disable_log_colors: bool,
|
#[arg(long, help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(rag_completer))]
|
||||||
/// Add a secret to the Coyote vault
|
pub rag: Option<String>,
|
||||||
#[arg(long, value_name = "SECRET_NAME", exclusive = true)]
|
/// Rebuild the RAG to sync document changes
|
||||||
pub add_secret: Option<String>,
|
#[arg(long, help_heading = "Agents, RAG & Macros")]
|
||||||
/// Decrypt a secret from the Coyote vault and print the plaintext
|
pub rebuild_rag: bool,
|
||||||
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))]
|
/// Execute a macro
|
||||||
pub get_secret: Option<String>,
|
#[arg(long = "macro", value_name = "MACRO", help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(macro_completer))]
|
||||||
/// Update an existing secret in the Coyote vault
|
pub macro_name: Option<String>,
|
||||||
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))]
|
|
||||||
pub update_secret: Option<String>,
|
/// List all available chat models
|
||||||
/// Delete a secret from the Coyote vault
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))]
|
pub list_models: bool,
|
||||||
pub delete_secret: Option<String>,
|
/// List all roles
|
||||||
/// List all secrets stored in the Coyote vault
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
#[arg(long, exclusive = true)]
|
pub list_roles: bool,
|
||||||
pub list_secrets: bool,
|
/// List all sessions
|
||||||
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
#[arg(long, exclusive = true, value_name = "CLIENT_NAME")]
|
pub list_sessions: bool,
|
||||||
pub authenticate: Option<Option<String>>,
|
/// List all agents
|
||||||
/// Authenticate with an OAuth-protected remote MCP server (e.g., --auth-mcp server_name)
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
#[arg(long, exclusive = true, value_name = "SERVER_NAME", add = ArgValueCompleter::new(mcp_server_completer))]
|
pub list_agents: bool,
|
||||||
pub auth_mcp: Option<String>,
|
/// List all RAGs
|
||||||
/// Generate static shell completion scripts
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
#[arg(long, value_name = "SHELL", value_enum)]
|
pub list_rags: bool,
|
||||||
pub completions: Option<ShellCompletion>,
|
/// List all macros
|
||||||
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
|
pub list_macros: bool,
|
||||||
|
/// List all installed skills
|
||||||
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
|
pub list_skills: bool,
|
||||||
|
|
||||||
|
/// Reinstall bundled assets, overwriting any local changes
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_name = "CATEGORY",
|
||||||
|
value_enum,
|
||||||
|
help_heading = "Installation & Updates"
|
||||||
|
)]
|
||||||
|
pub install: Option<AssetCategory>,
|
||||||
|
/// Install assets from a remote git repository (URL may be suffixed with #<ref>)
|
||||||
|
#[arg(long, value_name = "GIT_URL", help_heading = "Installation & Updates")]
|
||||||
|
pub install_from: Option<String>,
|
||||||
|
/// Restrict --install-from to a single asset category
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_name = "CATEGORY",
|
||||||
|
value_enum,
|
||||||
|
requires = "install_from",
|
||||||
|
help_heading = "Installation & Updates"
|
||||||
|
)]
|
||||||
|
pub filter: Option<InstallFilter>,
|
||||||
|
/// Overwrite all conflicts without prompting (used with --install-from)
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
requires = "install_from",
|
||||||
|
help_heading = "Installation & Updates"
|
||||||
|
)]
|
||||||
|
pub install_force: bool,
|
||||||
|
/// Sync models updates
|
||||||
|
#[arg(long, help_heading = "Installation & Updates")]
|
||||||
|
pub sync_models: bool,
|
||||||
/// Update Coyote to the latest release, or to a specific version
|
/// Update Coyote to the latest release, or to a specific version
|
||||||
#[arg(long, value_name = "VERSION")]
|
#[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")]
|
||||||
pub update: Option<Option<String>>,
|
pub update: Option<Option<String>>,
|
||||||
/// With --update, update even if Coyote was installed via a package manager
|
/// With --update, update even if Coyote was installed via a package manager
|
||||||
#[arg(long, requires = "update")]
|
#[arg(long, requires = "update", help_heading = "Installation & Updates")]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
|
|
||||||
|
/// Add a secret to the Coyote vault
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_name = "SECRET_NAME",
|
||||||
|
exclusive = true,
|
||||||
|
help_heading = "Vault & Secrets"
|
||||||
|
)]
|
||||||
|
pub add_secret: Option<String>,
|
||||||
|
/// Decrypt a secret from the Coyote vault and print the plaintext
|
||||||
|
#[arg(long, value_name = "SECRET_NAME", exclusive = true, help_heading = "Vault & Secrets", add = ArgValueCompleter::new(secrets_completer))]
|
||||||
|
pub get_secret: Option<String>,
|
||||||
|
/// Update an existing secret in the Coyote vault
|
||||||
|
#[arg(long, value_name = "SECRET_NAME", exclusive = true, help_heading = "Vault & Secrets", add = ArgValueCompleter::new(secrets_completer))]
|
||||||
|
pub update_secret: Option<String>,
|
||||||
|
/// Delete a secret from the Coyote vault
|
||||||
|
#[arg(long, value_name = "SECRET_NAME", exclusive = true, help_heading = "Vault & Secrets", add = ArgValueCompleter::new(secrets_completer))]
|
||||||
|
pub delete_secret: Option<String>,
|
||||||
|
/// List all secrets stored in the Coyote vault
|
||||||
|
#[arg(long, exclusive = true, help_heading = "Vault & Secrets")]
|
||||||
|
pub list_secrets: bool,
|
||||||
|
|
||||||
|
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
exclusive = true,
|
||||||
|
value_name = "CLIENT_NAME",
|
||||||
|
help_heading = "Authentication"
|
||||||
|
)]
|
||||||
|
pub authenticate: Option<Option<String>>,
|
||||||
|
/// Authenticate with an OAuth-protected remote MCP server (e.g., --auth-mcp server_name)
|
||||||
|
#[arg(long, exclusive = true, value_name = "SERVER_NAME", help_heading = "Authentication", add = ArgValueCompleter::new(mcp_server_completer))]
|
||||||
|
pub auth_mcp: Option<String>,
|
||||||
|
|
||||||
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
||||||
#[arg(long, value_name = "NAME")]
|
#[arg(long, value_name = "NAME", help_heading = "Sandbox")]
|
||||||
pub sandbox: Option<Option<String>>,
|
pub sandbox: Option<Option<String>>,
|
||||||
/// Create the sandbox without bootstrapping the host config or vault password file
|
/// Create the sandbox without bootstrapping the host config or vault password file
|
||||||
#[arg(long, requires = "sandbox")]
|
#[arg(long, requires = "sandbox", help_heading = "Sandbox")]
|
||||||
pub fresh: bool,
|
pub fresh: bool,
|
||||||
/// Skip discovery and application of all sbx mixins (user and built-in)
|
/// Skip discovery and application of all sbx mixins (user and built-in)
|
||||||
#[arg(long, requires = "sandbox")]
|
#[arg(long, requires = "sandbox", help_heading = "Sandbox")]
|
||||||
pub no_mixins: bool,
|
pub no_mixins: bool,
|
||||||
|
|
||||||
|
/// Display information
|
||||||
|
#[arg(long, help_heading = "Diagnostics & Tools")]
|
||||||
|
pub info: bool,
|
||||||
|
/// Build all configured Bash tool scripts
|
||||||
|
#[arg(long, help_heading = "Diagnostics & Tools")]
|
||||||
|
pub build_tools: bool,
|
||||||
|
/// Tail logs
|
||||||
|
#[arg(long, help_heading = "Diagnostics & Tools")]
|
||||||
|
pub tail_logs: bool,
|
||||||
|
/// Disable colored log output
|
||||||
|
#[arg(long, requires = "tail_logs", help_heading = "Diagnostics & Tools")]
|
||||||
|
pub disable_log_colors: bool,
|
||||||
|
|
||||||
|
/// Generate static shell completion scripts
|
||||||
|
#[arg(long, value_name = "SHELL", value_enum, help_heading = "Shell")]
|
||||||
|
pub completions: Option<ShellCompletion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ fn prepare_chat_completions(
|
|||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/openai/deployments/{}/chat/completions?api-version=2024-12-01-preview",
|
"{}/openai/deployments/{}/chat/completions?api-version=2024-12-01-preview",
|
||||||
&api_base,
|
api_base,
|
||||||
self_.model.real_name()
|
self_.model.real_name()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ fn prepare_embeddings(self_: &AzureOpenAIClient, data: &EmbeddingsData) -> Resul
|
|||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/openai/deployments/{}/embeddings?api-version=2024-10-21",
|
"{}/openai/deployments/{}/embeddings?api-version=2024-10-21",
|
||||||
&api_base,
|
api_base,
|
||||||
self_.model.real_name()
|
self_.model.real_name()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -325,6 +325,7 @@ fn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Resu
|
|||||||
mut messages,
|
mut messages,
|
||||||
temperature,
|
temperature,
|
||||||
top_p,
|
top_p,
|
||||||
|
reasoning_effort,
|
||||||
functions,
|
functions,
|
||||||
stream: _,
|
stream: _,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -396,6 +397,11 @@ fn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Resu
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
for tool_result in tool_results {
|
for tool_result in tool_results {
|
||||||
|
if let Some(round_text) = &tool_result.text {
|
||||||
|
assistant_parts.push(json!({
|
||||||
|
"text": round_text,
|
||||||
|
}))
|
||||||
|
}
|
||||||
assistant_parts.push(json!({
|
assistant_parts.push(json!({
|
||||||
"toolUse": {
|
"toolUse": {
|
||||||
"toolUseId": tool_result.call.id,
|
"toolUseId": tool_result.call.id,
|
||||||
@@ -457,6 +463,9 @@ fn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Resu
|
|||||||
if let Some(v) = top_p {
|
if let Some(v) = top_p {
|
||||||
body["inferenceConfig"]["topP"] = v.into();
|
body["inferenceConfig"]["topP"] = v.into();
|
||||||
}
|
}
|
||||||
|
if let Some(v) = reasoning_effort {
|
||||||
|
body["additionalModelRequestFields"] = json!({ "output_config": { "effort": v } });
|
||||||
|
}
|
||||||
if let Some(functions) = functions {
|
if let Some(functions) = functions {
|
||||||
let tools: Vec<_> = functions
|
let tools: Vec<_> = functions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -520,7 +529,7 @@ fn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
|
|||||||
bail!("Invalid response data: {data}");
|
bail!("Invalid response data: {data}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = ChatCompletionsOutput { text, tool_calls };
|
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-3
@@ -168,12 +168,22 @@ pub async fn claude_chat_completions_streaming(
|
|||||||
let mut function_arguments = String::new();
|
let mut function_arguments = String::new();
|
||||||
let mut function_id = String::new();
|
let mut function_id = String::new();
|
||||||
let mut reasoning_state = 0;
|
let mut reasoning_state = 0;
|
||||||
|
let mut thinking_text = String::new();
|
||||||
|
let mut thinking_signature = String::new();
|
||||||
let handle = |message: SseMessage| -> Result<bool> {
|
let handle = |message: SseMessage| -> Result<bool> {
|
||||||
let data: Value = serde_json::from_str(&message.data)?;
|
let data: Value = serde_json::from_str(&message.data)?;
|
||||||
debug!("stream-data: {data}");
|
debug!("stream-data: {data}");
|
||||||
if let Some(typ) = data["type"].as_str() {
|
if let Some(typ) = data["type"].as_str() {
|
||||||
match typ {
|
match typ {
|
||||||
"content_block_start" => {
|
"content_block_start" => {
|
||||||
|
if let (Some("redacted_thinking"), Some(redacted_data)) = (
|
||||||
|
data["content_block"]["type"].as_str(),
|
||||||
|
data["content_block"]["data"].as_str(),
|
||||||
|
) {
|
||||||
|
handler.thinking_block(ThinkingBlock::RedactedThinking {
|
||||||
|
data: redacted_data.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
if let (Some("tool_use"), Some(name), Some(id)) = (
|
if let (Some("tool_use"), Some(name), Some(id)) = (
|
||||||
data["content_block"]["type"].as_str(),
|
data["content_block"]["type"].as_str(),
|
||||||
data["content_block"]["name"].as_str(),
|
data["content_block"]["name"].as_str(),
|
||||||
@@ -206,7 +216,10 @@ pub async fn claude_chat_completions_streaming(
|
|||||||
handler.text("<think>\n")?;
|
handler.text("<think>\n")?;
|
||||||
reasoning_state = 1;
|
reasoning_state = 1;
|
||||||
}
|
}
|
||||||
|
thinking_text.push_str(text);
|
||||||
handler.text(text)?;
|
handler.text(text)?;
|
||||||
|
} else if let Some(signature) = data["delta"]["signature"].as_str() {
|
||||||
|
thinking_signature.push_str(signature);
|
||||||
} else if let (true, Some(partial_json)) = (
|
} else if let (true, Some(partial_json)) = (
|
||||||
!function_name.is_empty(),
|
!function_name.is_empty(),
|
||||||
data["delta"]["partial_json"].as_str(),
|
data["delta"]["partial_json"].as_str(),
|
||||||
@@ -218,6 +231,10 @@ pub async fn claude_chat_completions_streaming(
|
|||||||
if reasoning_state == 1 {
|
if reasoning_state == 1 {
|
||||||
handler.text("\n</think>\n\n")?;
|
handler.text("\n</think>\n\n")?;
|
||||||
reasoning_state = 0;
|
reasoning_state = 0;
|
||||||
|
handler.thinking_block(ThinkingBlock::Thinking {
|
||||||
|
thinking: std::mem::take(&mut thinking_text),
|
||||||
|
signature: std::mem::take(&mut thinking_signature),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if !function_name.is_empty() {
|
if !function_name.is_empty() {
|
||||||
let arguments: Value = if function_arguments.is_empty() {
|
let arguments: Value = if function_arguments.is_empty() {
|
||||||
@@ -251,6 +268,7 @@ pub fn claude_build_chat_completions_body(
|
|||||||
mut messages,
|
mut messages,
|
||||||
temperature,
|
temperature,
|
||||||
top_p,
|
top_p,
|
||||||
|
reasoning_effort,
|
||||||
functions,
|
functions,
|
||||||
stream,
|
stream,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -312,13 +330,25 @@ pub fn claude_build_chat_completions_body(
|
|||||||
}) => {
|
}) => {
|
||||||
let mut assistant_parts = vec![];
|
let mut assistant_parts = vec![];
|
||||||
let mut user_parts = vec![];
|
let mut user_parts = vec![];
|
||||||
if !text.is_empty() {
|
for (index, tool_result) in tool_results.iter().enumerate() {
|
||||||
|
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!({
|
assistant_parts.push(json!({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": text,
|
"text": round_text,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
for tool_result in tool_results {
|
}
|
||||||
assistant_parts.push(json!({
|
assistant_parts.push(json!({
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": tool_result.call.id,
|
"id": tool_result.call.id,
|
||||||
@@ -369,6 +399,9 @@ pub fn claude_build_chat_completions_body(
|
|||||||
if let Some(v) = top_p {
|
if let Some(v) = top_p {
|
||||||
body["top_p"] = v.into();
|
body["top_p"] = v.into();
|
||||||
}
|
}
|
||||||
|
if let Some(v) = reasoning_effort {
|
||||||
|
body["output_config"] = json!({ "effort": v });
|
||||||
|
}
|
||||||
if stream {
|
if stream {
|
||||||
body["stream"] = true.into();
|
body["stream"] = true.into();
|
||||||
}
|
}
|
||||||
@@ -399,12 +432,24 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
|
|||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut reasoning = None;
|
let mut reasoning = None;
|
||||||
let mut tool_calls = vec![];
|
let mut tool_calls = vec![];
|
||||||
|
let mut thinking = vec![];
|
||||||
if let Some(list) = data["content"].as_array() {
|
if let Some(list) = data["content"].as_array() {
|
||||||
for item in list {
|
for item in list {
|
||||||
match item["type"].as_str() {
|
match item["type"].as_str() {
|
||||||
Some("thinking") => {
|
Some("thinking") => {
|
||||||
if let Some(v) = item["thinking"].as_str() {
|
if let Some(v) = item["thinking"].as_str() {
|
||||||
reasoning = Some(v.to_string());
|
reasoning = Some(v.to_string());
|
||||||
|
thinking.push(ThinkingBlock::Thinking {
|
||||||
|
thinking: v.to_string(),
|
||||||
|
signature: item["signature"].as_str().unwrap_or_default().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("redacted_thinking") => {
|
||||||
|
if let Some(v) = item["data"].as_str() {
|
||||||
|
thinking.push(ThinkingBlock::RedactedThinking {
|
||||||
|
data: v.to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("text") => {
|
Some("text") => {
|
||||||
@@ -443,6 +488,7 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
|
|||||||
let output = ChatCompletionsOutput {
|
let output = ChatCompletionsOutput {
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
tool_calls,
|
tool_calls,
|
||||||
|
thinking,
|
||||||
};
|
};
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ impl OAuthProvider for ClaudeOAuthProvider {
|
|||||||
"https://console.anthropic.com/oauth/code/callback"
|
"https://console.anthropic.com/oauth/code/callback"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scopes(&self) -> &str {
|
fn scopes(&self) -> String {
|
||||||
"org:create_api_key user:profile user:inference"
|
"org:create_api_key user:profile user:inference".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
|
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
|
||||||
|
|||||||
@@ -244,6 +244,6 @@ fn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
|
|||||||
if text.is_empty() && tool_calls.is_empty() {
|
if text.is_empty() && tool_calls.is_empty() {
|
||||||
bail!("Invalid response data: {data}");
|
bail!("Invalid response data: {data}");
|
||||||
}
|
}
|
||||||
let output = ChatCompletionsOutput { text, tool_calls };
|
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-3
@@ -286,6 +286,7 @@ pub struct ChatCompletionsData {
|
|||||||
pub messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
pub temperature: Option<f64>,
|
pub temperature: Option<f64>,
|
||||||
pub top_p: Option<f64>,
|
pub top_p: Option<f64>,
|
||||||
|
pub reasoning_effort: Option<String>,
|
||||||
pub functions: Option<Vec<FunctionDeclaration>>,
|
pub functions: Option<Vec<FunctionDeclaration>>,
|
||||||
pub stream: bool,
|
pub stream: bool,
|
||||||
}
|
}
|
||||||
@@ -294,6 +295,7 @@ pub struct ChatCompletionsData {
|
|||||||
pub struct ChatCompletionsOutput {
|
pub struct ChatCompletionsOutput {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub tool_calls: Vec<ToolCall>,
|
pub tool_calls: Vec<ToolCall>,
|
||||||
|
pub thinking: Vec<ThinkingBlock>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatCompletionsOutput {
|
impl ChatCompletionsOutput {
|
||||||
@@ -401,10 +403,25 @@ pub async fn create_openai_compatible_client_config(
|
|||||||
};
|
};
|
||||||
config["api_base"] = api_base.into();
|
config["api_base"] = api_base.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)?;
|
let api_key = prompt_input_string("API Key", false, None)?;
|
||||||
if !api_key.is_empty() {
|
if !api_key.is_empty() {
|
||||||
config["api_key"] = api_key.into();
|
config["api_key"] = api_key.into();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let model = set_client_models_config(&mut config, &name).await?;
|
let model = set_client_models_config(&mut config, &name).await?;
|
||||||
let clients = json!(vec![config]);
|
let clients = json!(vec![config]);
|
||||||
@@ -434,6 +451,7 @@ pub async fn call_chat_completions(
|
|||||||
let ChatCompletionsOutput {
|
let ChatCompletionsOutput {
|
||||||
mut text,
|
mut text,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
|
thinking,
|
||||||
..
|
..
|
||||||
} = ret;
|
} = ret;
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
@@ -444,7 +462,10 @@ pub async fn call_chat_completions(
|
|||||||
ctx.app.config.print_markdown(&text)?;
|
ctx.app.config.print_markdown(&text)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let tool_results = eval_tool_calls(ctx, tool_calls).await?;
|
let mut tool_results = eval_tool_calls(ctx, tool_calls).await?;
|
||||||
|
if let Some(first) = tool_results.first_mut() {
|
||||||
|
first.thinking = thinking;
|
||||||
|
}
|
||||||
tool_results
|
tool_results
|
||||||
.iter()
|
.iter()
|
||||||
.for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone()));
|
.for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone()));
|
||||||
@@ -478,13 +499,16 @@ pub async fn call_chat_completions_streaming(
|
|||||||
|
|
||||||
render_ret?;
|
render_ret?;
|
||||||
|
|
||||||
let (text, tool_calls) = handler.take();
|
let (text, tool_calls, thinking) = handler.take();
|
||||||
match send_ret {
|
match send_ret {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if !text.is_empty() && !text.ends_with('\n') {
|
if !text.is_empty() && !text.ends_with('\n') {
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
let tool_results = eval_tool_calls(ctx, tool_calls).await?;
|
let mut tool_results = eval_tool_calls(ctx, tool_calls).await?;
|
||||||
|
if let Some(first) = tool_results.first_mut() {
|
||||||
|
first.thinking = thinking;
|
||||||
|
}
|
||||||
tool_results
|
tool_results
|
||||||
.iter()
|
.iter()
|
||||||
.for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone()));
|
.for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone()));
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ impl OAuthProvider for GeminiOAuthProvider {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scopes(&self) -> &str {
|
fn scopes(&self) -> String {
|
||||||
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email"
|
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_secret(&self) -> Option<&str> {
|
fn client_secret(&self) -> Option<&str> {
|
||||||
|
|||||||
+20
-2
@@ -118,6 +118,9 @@ impl MessageContent {
|
|||||||
lines.push(text.clone())
|
lines.push(text.clone())
|
||||||
}
|
}
|
||||||
for tool_result in tool_results {
|
for tool_result in tool_results {
|
||||||
|
if let Some(round_text) = &tool_result.text {
|
||||||
|
lines.push(round_text.clone())
|
||||||
|
}
|
||||||
let mut parts = vec!["Call".to_string()];
|
let mut parts = vec!["Call".to_string()];
|
||||||
if let Some((agent_name, functions)) = agent_info
|
if let Some((agent_name, functions)) = agent_info
|
||||||
&& functions.contains(&tool_result.call.name)
|
&& functions.contains(&tool_result.call.name)
|
||||||
@@ -185,6 +188,17 @@ pub struct ImageUrl {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An extended-thinking block returned by Anthropic-protocol models.
|
||||||
|
/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`)
|
||||||
|
/// so blocks can be replayed verbatim, signature intact, in subsequent
|
||||||
|
/// tool-loop rounds as the API requires.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ThinkingBlock {
|
||||||
|
Thinking { thinking: String, signature: String },
|
||||||
|
RedactedThinking { data: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct MessageContentToolCalls {
|
pub struct MessageContentToolCalls {
|
||||||
pub tool_results: Vec<ToolResult>,
|
pub tool_results: Vec<ToolResult>,
|
||||||
@@ -201,9 +215,13 @@ impl MessageContentToolCalls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge(&mut self, tool_results: Vec<ToolResult>, _text: String) {
|
pub fn merge(&mut self, mut tool_results: Vec<ToolResult>, text: String) {
|
||||||
|
if !text.is_empty()
|
||||||
|
&& let Some(first) = tool_results.first_mut()
|
||||||
|
{
|
||||||
|
first.text = Some(text);
|
||||||
|
}
|
||||||
self.tool_results.extend(tool_results);
|
self.tool_results.extend(tool_results);
|
||||||
self.text.clear();
|
|
||||||
self.sequence = true;
|
self.sequence = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod common;
|
|||||||
mod gemini_oauth;
|
mod gemini_oauth;
|
||||||
mod message;
|
mod message;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
mod openai_compatible_oauth;
|
||||||
mod openai_oauth;
|
mod openai_oauth;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
mod macros;
|
mod macros;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use super::{
|
|||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::utils::{estimate_token_length, strip_think_tag};
|
use crate::utils::{estimate_token_length, strip_think_tag};
|
||||||
|
|
||||||
|
use super::oauth::OAuthConfig;
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -289,6 +290,14 @@ impl Model {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn reasoning_levels(&self) -> &[String] {
|
||||||
|
&self.data.reasoning_levels
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_reasoning_effort(&self) -> Option<&str> {
|
||||||
|
self.data.default_reasoning_effort.as_deref()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -316,6 +325,10 @@ pub struct ModelData {
|
|||||||
pub supports_vision: bool,
|
pub supports_vision: bool,
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub supports_function_calling: bool,
|
pub supports_function_calling: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub reasoning_levels: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default_reasoning_effort: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
no_stream: bool,
|
no_stream: bool,
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
@@ -345,6 +358,8 @@ impl ModelData {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProviderModels {
|
pub struct ProviderModels {
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub oauth: Option<OAuthConfig>,
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+958
-40
File diff suppressed because it is too large
Load Diff
+50
-18
@@ -356,6 +356,7 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
|
|||||||
messages,
|
messages,
|
||||||
temperature,
|
temperature,
|
||||||
top_p,
|
top_p,
|
||||||
|
reasoning_effort,
|
||||||
functions,
|
functions,
|
||||||
stream,
|
stream,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -369,7 +370,7 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
|
|||||||
match content {
|
match content {
|
||||||
MessageContent::ToolCalls(MessageContentToolCalls {
|
MessageContent::ToolCalls(MessageContentToolCalls {
|
||||||
tool_results,
|
tool_results,
|
||||||
text: _,
|
text,
|
||||||
sequence,
|
sequence,
|
||||||
}) => {
|
}) => {
|
||||||
if !sequence {
|
if !sequence {
|
||||||
@@ -386,9 +387,12 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut messages = vec![
|
let mut assistant_message =
|
||||||
json!({ "role": MessageRole::Assistant, "tool_calls": tool_calls }),
|
json!({ "role": MessageRole::Assistant, "tool_calls": tool_calls });
|
||||||
];
|
if !text.is_empty() {
|
||||||
|
assistant_message["content"] = strip_think_tag(&text).into();
|
||||||
|
}
|
||||||
|
let mut messages = vec![assistant_message];
|
||||||
for tool_result in tool_results {
|
for tool_result in tool_results {
|
||||||
messages.push(json!({
|
messages.push(json!({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
@@ -398,9 +402,13 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
|
|||||||
}
|
}
|
||||||
messages
|
messages
|
||||||
} else {
|
} else {
|
||||||
tool_results.into_iter().flat_map(|tool_result| {
|
tool_results.into_iter().enumerate().flat_map(|(index, tool_result)| {
|
||||||
vec![
|
let round_text = if index == 0 && !text.is_empty() {
|
||||||
json!({
|
Some(text.clone())
|
||||||
|
} else {
|
||||||
|
tool_result.text.clone()
|
||||||
|
};
|
||||||
|
let mut assistant_message = json!({
|
||||||
"role": MessageRole::Assistant,
|
"role": MessageRole::Assistant,
|
||||||
"tool_calls": [
|
"tool_calls": [
|
||||||
{
|
{
|
||||||
@@ -412,7 +420,12 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}),
|
});
|
||||||
|
if let Some(round_text) = round_text {
|
||||||
|
assistant_message["content"] = strip_think_tag(&round_text).into();
|
||||||
|
}
|
||||||
|
vec![
|
||||||
|
assistant_message,
|
||||||
json!({
|
json!({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"content": tool_result.output.to_string(),
|
"content": tool_result.output.to_string(),
|
||||||
@@ -454,6 +467,9 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
|
|||||||
if let Some(v) = top_p {
|
if let Some(v) = top_p {
|
||||||
body["top_p"] = v.into();
|
body["top_p"] = v.into();
|
||||||
}
|
}
|
||||||
|
if let Some(v) = reasoning_effort {
|
||||||
|
body["reasoning_effort"] = v.into();
|
||||||
|
}
|
||||||
if stream {
|
if stream {
|
||||||
body["stream"] = true.into();
|
body["stream"] = true.into();
|
||||||
}
|
}
|
||||||
@@ -517,7 +533,7 @@ pub fn openai_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
|
|||||||
} else {
|
} else {
|
||||||
text.to_string()
|
text.to_string()
|
||||||
};
|
};
|
||||||
let output = ChatCompletionsOutput { text, tool_calls };
|
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,6 +550,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
|
|||||||
messages,
|
messages,
|
||||||
temperature,
|
temperature,
|
||||||
top_p,
|
top_p,
|
||||||
|
reasoning_effort,
|
||||||
functions,
|
functions,
|
||||||
stream,
|
stream,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -547,24 +564,36 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
|
|||||||
match content {
|
match content {
|
||||||
MessageContent::ToolCalls(MessageContentToolCalls {
|
MessageContent::ToolCalls(MessageContentToolCalls {
|
||||||
tool_results,
|
tool_results,
|
||||||
text: _,
|
text,
|
||||||
sequence: _,
|
sequence: _,
|
||||||
}) => tool_results
|
}) => tool_results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|tool_result| {
|
.enumerate()
|
||||||
vec![
|
.flat_map(|(index, tool_result)| {
|
||||||
json!({
|
let round_text = if index == 0 && !text.is_empty() {
|
||||||
|
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",
|
"type": "function_call",
|
||||||
"call_id": tool_result.call.id,
|
"call_id": tool_result.call.id,
|
||||||
"name": tool_result.call.name,
|
"name": tool_result.call.name,
|
||||||
"arguments": tool_result.call.arguments.to_string(),
|
"arguments": tool_result.call.arguments.to_string(),
|
||||||
}),
|
}));
|
||||||
json!({
|
items.push(json!({
|
||||||
"type": "function_call_output",
|
"type": "function_call_output",
|
||||||
"call_id": tool_result.call.id,
|
"call_id": tool_result.call.id,
|
||||||
"output": tool_result.output.to_string(),
|
"output": tool_result.output.to_string(),
|
||||||
}),
|
}));
|
||||||
]
|
items
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => {
|
MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => {
|
||||||
@@ -590,6 +619,9 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
|
|||||||
if let Some(v) = top_p {
|
if let Some(v) = top_p {
|
||||||
body["top_p"] = v.into();
|
body["top_p"] = v.into();
|
||||||
}
|
}
|
||||||
|
if let Some(v) = reasoning_effort {
|
||||||
|
body["reasoning"] = json!({ "effort": v });
|
||||||
|
}
|
||||||
if stream {
|
if stream {
|
||||||
body["stream"] = true.into();
|
body["stream"] = true.into();
|
||||||
}
|
}
|
||||||
@@ -664,7 +696,7 @@ pub fn openai_extract_responses(data: &Value) -> Result<ChatCompletionsOutput> {
|
|||||||
if text.is_empty() && tool_calls.is_empty() {
|
if text.is_empty() && tool_calls.is_empty() {
|
||||||
bail!("Invalid response data: {data}");
|
bail!("Invalid response data: {data}");
|
||||||
}
|
}
|
||||||
Ok(ChatCompletionsOutput { text, tool_calls })
|
Ok(ChatCompletionsOutput { text, tool_calls, ..Default::default() })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn openai_responses_streaming(
|
pub async fn openai_responses_streaming(
|
||||||
|
|||||||
+122
-36
@@ -1,16 +1,21 @@
|
|||||||
|
use super::access_token::get_access_token;
|
||||||
|
use super::oauth;
|
||||||
use super::openai::*;
|
use super::openai::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use reqwest::RequestBuilder;
|
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use oauth::OAuthConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct OpenAICompatibleConfig {
|
pub struct OpenAICompatibleConfig {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
|
pub auth: Option<String>,
|
||||||
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
@@ -24,78 +29,159 @@ impl OpenAICompatibleClient {
|
|||||||
create_client_config!([]);
|
create_client_config!([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_client_trait!(
|
#[async_trait::async_trait]
|
||||||
OpenAICompatibleClient,
|
impl Client for OpenAICompatibleClient {
|
||||||
(
|
client_common_fns!();
|
||||||
prepare_chat_completions,
|
|
||||||
openai_chat_completions,
|
|
||||||
openai_chat_completions_streaming
|
|
||||||
),
|
|
||||||
(prepare_embeddings, openai_embeddings),
|
|
||||||
(prepare_rerank, generic_rerank),
|
|
||||||
);
|
|
||||||
|
|
||||||
fn prepare_chat_completions(
|
fn supports_oauth(&self) -> bool {
|
||||||
|
self.config.auth.as_deref() == Some("oauth")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions_inner(
|
||||||
|
&self,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
data: ChatCompletionsData,
|
||||||
|
) -> Result<ChatCompletionsOutput> {
|
||||||
|
let request_data = prepare_chat_completions(self, client, data).await?;
|
||||||
|
let builder = self.request_builder(client, request_data);
|
||||||
|
|
||||||
|
openai_chat_completions(builder, self.model()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions_streaming_inner(
|
||||||
|
&self,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
handler: &mut SseHandler,
|
||||||
|
data: ChatCompletionsData,
|
||||||
|
) -> Result<()> {
|
||||||
|
let request_data = prepare_chat_completions(self, client, data).await?;
|
||||||
|
let builder = self.request_builder(client, request_data);
|
||||||
|
|
||||||
|
openai_chat_completions_streaming(builder, handler, self.model()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embeddings_inner(
|
||||||
|
&self,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
data: &EmbeddingsData,
|
||||||
|
) -> Result<EmbeddingsOutput> {
|
||||||
|
let request_data = prepare_embeddings(self, client, data).await?;
|
||||||
|
let builder = self.request_builder(client, request_data);
|
||||||
|
|
||||||
|
openai_embeddings(builder, self.model()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rerank_inner(
|
||||||
|
&self,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
data: &RerankData,
|
||||||
|
) -> Result<RerankOutput> {
|
||||||
|
let request_data = prepare_rerank(self, client, data).await?;
|
||||||
|
let builder = self.request_builder(client, request_data);
|
||||||
|
|
||||||
|
generic_rerank(builder, self.model()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_chat_completions(
|
||||||
self_: &OpenAICompatibleClient,
|
self_: &OpenAICompatibleClient,
|
||||||
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<RequestData> {
|
) -> Result<RequestData> {
|
||||||
let api_key = self_.get_api_key().ok();
|
|
||||||
let api_base = get_api_base_ext(self_)?;
|
let api_base = get_api_base_ext(self_)?;
|
||||||
|
|
||||||
let url = format!("{api_base}/chat/completions");
|
let url = format!("{api_base}/chat/completions");
|
||||||
|
|
||||||
let body = openai_build_chat_completions_body(data, &self_.model);
|
let body = openai_build_chat_completions_body(data, &self_.model);
|
||||||
|
|
||||||
let mut request_data = RequestData::new(url, body);
|
let mut request_data = RequestData::new(url, body);
|
||||||
|
|
||||||
if let Some(api_key) = api_key {
|
apply_auth(self_, client, &mut request_data).await?;
|
||||||
request_data.bearer_auth(api_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_data)
|
Ok(request_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_embeddings(
|
async fn prepare_embeddings(
|
||||||
self_: &OpenAICompatibleClient,
|
self_: &OpenAICompatibleClient,
|
||||||
|
client: &ReqwestClient,
|
||||||
data: &EmbeddingsData,
|
data: &EmbeddingsData,
|
||||||
) -> Result<RequestData> {
|
) -> Result<RequestData> {
|
||||||
let api_key = self_.get_api_key().ok();
|
|
||||||
let api_base = get_api_base_ext(self_)?;
|
let api_base = get_api_base_ext(self_)?;
|
||||||
|
|
||||||
let url = format!("{api_base}/embeddings");
|
let url = format!("{api_base}/embeddings");
|
||||||
|
|
||||||
let body = openai_build_embeddings_body(data, &self_.model);
|
let body = openai_build_embeddings_body(data, &self_.model);
|
||||||
|
|
||||||
let mut request_data = RequestData::new(url, body);
|
let mut request_data = RequestData::new(url, body);
|
||||||
|
|
||||||
if let Some(api_key) = api_key {
|
apply_auth(self_, client, &mut request_data).await?;
|
||||||
request_data.bearer_auth(api_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_data)
|
Ok(request_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_rerank(self_: &OpenAICompatibleClient, data: &RerankData) -> Result<RequestData> {
|
async fn prepare_rerank(
|
||||||
let api_key = self_.get_api_key().ok();
|
self_: &OpenAICompatibleClient,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
data: &RerankData,
|
||||||
|
) -> Result<RequestData> {
|
||||||
let api_base = get_api_base_ext(self_)?;
|
let api_base = get_api_base_ext(self_)?;
|
||||||
|
|
||||||
let url = if self_.name().starts_with("ernie") {
|
let url = if self_.name().starts_with("ernie") {
|
||||||
format!("{api_base}/rerankers")
|
format!("{api_base}/rerankers")
|
||||||
} else {
|
} else {
|
||||||
format!("{api_base}/rerank")
|
format!("{api_base}/rerank")
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = generic_build_rerank_body(data, &self_.model);
|
let body = generic_build_rerank_body(data, &self_.model);
|
||||||
|
|
||||||
let mut request_data = RequestData::new(url, body);
|
let mut request_data = RequestData::new(url, body);
|
||||||
|
|
||||||
if let Some(api_key) = api_key {
|
apply_auth(self_, client, &mut request_data).await?;
|
||||||
request_data.bearer_auth(api_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_data)
|
Ok(request_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
fn get_api_base_ext(self_: &OpenAICompatibleClient) -> Result<String> {
|
||||||
let api_base = match self_.get_api_base() {
|
let api_base = match self_.get_api_base() {
|
||||||
Ok(v) => v,
|
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"
|
"http://localhost:1455/auth/callback"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scopes(&self) -> &str {
|
fn scopes(&self) -> String {
|
||||||
"openid profile email offline_access"
|
"openid profile email offline_access".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_request_format(&self) -> TokenRequestFormat {
|
fn token_request_format(&self) -> TokenRequestFormat {
|
||||||
|
|||||||
+13
-4
@@ -1,4 +1,4 @@
|
|||||||
use super::{ToolCall, catch_error};
|
use super::{ThinkingBlock, ToolCall, catch_error};
|
||||||
use crate::utils::AbortSignal;
|
use crate::utils::AbortSignal;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
@@ -13,6 +13,7 @@ pub struct SseHandler {
|
|||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
buffer: String,
|
buffer: String,
|
||||||
tool_calls: Vec<ToolCall>,
|
tool_calls: Vec<ToolCall>,
|
||||||
|
thinking: Vec<ThinkingBlock>,
|
||||||
last_tool_calls: Vec<ToolCall>,
|
last_tool_calls: Vec<ToolCall>,
|
||||||
max_call_repeats: usize,
|
max_call_repeats: usize,
|
||||||
call_repeat_chain_len: usize,
|
call_repeat_chain_len: usize,
|
||||||
@@ -26,6 +27,7 @@ impl SseHandler {
|
|||||||
abort_signal,
|
abort_signal,
|
||||||
buffer: String::new(),
|
buffer: String::new(),
|
||||||
tool_calls: Vec::new(),
|
tool_calls: Vec::new(),
|
||||||
|
thinking: Vec::new(),
|
||||||
last_tool_calls: Vec::new(),
|
last_tool_calls: Vec::new(),
|
||||||
max_call_repeats: 2,
|
max_call_repeats: 2,
|
||||||
call_repeat_chain_len: 3,
|
call_repeat_chain_len: 3,
|
||||||
@@ -170,6 +172,10 @@ impl SseHandler {
|
|||||||
message
|
message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn thinking_block(&mut self, block: ThinkingBlock) {
|
||||||
|
self.thinking.push(block);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn abort(&self) -> AbortSignal {
|
pub fn abort(&self) -> AbortSignal {
|
||||||
self.abort_signal.clone()
|
self.abort_signal.clone()
|
||||||
}
|
}
|
||||||
@@ -179,11 +185,14 @@ impl SseHandler {
|
|||||||
&self.last_tool_calls
|
&self.last_tool_calls
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take(self) -> (String, Vec<ToolCall>) {
|
pub fn take(self) -> (String, Vec<ToolCall>, Vec<ThinkingBlock>) {
|
||||||
let Self {
|
let Self {
|
||||||
buffer, tool_calls, ..
|
buffer,
|
||||||
|
tool_calls,
|
||||||
|
thinking,
|
||||||
|
..
|
||||||
} = self;
|
} = self;
|
||||||
(buffer, tool_calls)
|
(buffer, tool_calls, thinking)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-5
@@ -322,7 +322,11 @@ fn gemini_extract_chat_completions_text(data: &Value) -> Result<ChatCompletionsO
|
|||||||
bail!("Invalid response data: {data}");
|
bail!("Invalid response data: {data}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let output = ChatCompletionsOutput { text, tool_calls };
|
let output = ChatCompletionsOutput {
|
||||||
|
text,
|
||||||
|
tool_calls,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +338,7 @@ pub fn gemini_build_chat_completions_body(
|
|||||||
mut messages,
|
mut messages,
|
||||||
temperature,
|
temperature,
|
||||||
top_p,
|
top_p,
|
||||||
|
reasoning_effort,
|
||||||
functions,
|
functions,
|
||||||
stream: _,
|
stream: _,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -371,8 +376,15 @@ pub fn gemini_build_chat_completions_body(
|
|||||||
.collect();
|
.collect();
|
||||||
vec![json!({ "role": role, "parts": parts })]
|
vec![json!({ "role": role, "parts": parts })]
|
||||||
},
|
},
|
||||||
MessageContent::ToolCalls(MessageContentToolCalls { tool_results, .. }) => {
|
MessageContent::ToolCalls(MessageContentToolCalls { tool_results, text, .. }) => {
|
||||||
let model_parts: Vec<Value> = tool_results.iter().map(|tool_result| {
|
let mut model_parts: Vec<Value> = vec![];
|
||||||
|
if !text.is_empty() {
|
||||||
|
model_parts.push(json!({ "text": text }));
|
||||||
|
}
|
||||||
|
for tool_result in tool_results.iter() {
|
||||||
|
if let Some(round_text) = &tool_result.text {
|
||||||
|
model_parts.push(json!({ "text": round_text }));
|
||||||
|
}
|
||||||
let mut part = json!({
|
let mut part = json!({
|
||||||
"functionCall": {
|
"functionCall": {
|
||||||
"name": tool_result.call.name,
|
"name": tool_result.call.name,
|
||||||
@@ -382,8 +394,8 @@ pub fn gemini_build_chat_completions_body(
|
|||||||
if let Some(sig) = &tool_result.call.thought_signature {
|
if let Some(sig) = &tool_result.call.thought_signature {
|
||||||
part["thoughtSignature"] = json!(sig);
|
part["thoughtSignature"] = json!(sig);
|
||||||
}
|
}
|
||||||
part
|
model_parts.push(part);
|
||||||
}).collect();
|
}
|
||||||
let function_parts: Vec<Value> = tool_results.into_iter().map(|tool_result| {
|
let function_parts: Vec<Value> = tool_results.into_iter().map(|tool_result| {
|
||||||
json!({
|
json!({
|
||||||
"functionResponse": {
|
"functionResponse": {
|
||||||
@@ -426,6 +438,9 @@ pub fn gemini_build_chat_completions_body(
|
|||||||
if let Some(v) = top_p {
|
if let Some(v) = top_p {
|
||||||
body["generationConfig"]["topP"] = v.into();
|
body["generationConfig"]["topP"] = v.into();
|
||||||
}
|
}
|
||||||
|
if let Some(v) = reasoning_effort {
|
||||||
|
body["generationConfig"]["thinking_config"] = json!({"thinking_level": v});
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(functions) = functions {
|
if let Some(functions) = functions {
|
||||||
// Gemini doesn't support functions with parameters that have empty properties, so we need to patch it.
|
// Gemini doesn't support functions with parameters that have empty properties, so we need to patch it.
|
||||||
|
|||||||
+109
-11
@@ -43,6 +43,8 @@ pub struct Agent {
|
|||||||
graph_rags: HashMap<String, Arc<Rag>>,
|
graph_rags: HashMap<String, Arc<Rag>>,
|
||||||
model: Model,
|
model: Model,
|
||||||
vault: GlobalVault,
|
vault: GlobalVault,
|
||||||
|
is_graph: bool,
|
||||||
|
enabled_tools: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
@@ -219,7 +221,7 @@ impl Agent {
|
|||||||
&& !matches!(agent_config.memory, Some(false))
|
&& !matches!(agent_config.memory, Some(false))
|
||||||
&& !matches!(app.memory, Some(false))
|
&& !matches!(app.memory, Some(false))
|
||||||
{
|
{
|
||||||
let memory_exists = paths::global_memory_index_path().exists()
|
let memory_exists = paths::global_memory_index_file().exists()
|
||||||
|| env::current_dir()
|
|| env::current_dir()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|cwd| memory::discover_workspace_memory(&cwd))
|
.and_then(|cwd| memory::discover_workspace_memory(&cwd))
|
||||||
@@ -243,6 +245,8 @@ impl Agent {
|
|||||||
graph_rags,
|
graph_rags,
|
||||||
model,
|
model,
|
||||||
vault: app_state.vault.clone(),
|
vault: app_state.vault.clone(),
|
||||||
|
is_graph: graph_for_rag.is_some(),
|
||||||
|
enabled_tools: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +343,10 @@ impl Agent {
|
|||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_graph(&self) -> bool {
|
||||||
|
self.is_graph
|
||||||
|
}
|
||||||
|
|
||||||
pub fn functions(&self) -> &Functions {
|
pub fn functions(&self) -> &Functions {
|
||||||
&self.functions
|
&self.functions
|
||||||
}
|
}
|
||||||
@@ -359,6 +367,10 @@ impl Agent {
|
|||||||
&self.config.mcp_servers
|
&self.config.mcp_servers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn spawnable_agents(&self) -> Option<&[String]> {
|
||||||
|
self.config.spawnable_agents.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn skills_enabled(&self) -> Option<bool> {
|
pub fn skills_enabled(&self) -> Option<bool> {
|
||||||
self.config.skills_enabled
|
self.config.skills_enabled
|
||||||
}
|
}
|
||||||
@@ -575,8 +587,12 @@ impl RoleLike for Agent {
|
|||||||
self.config.top_p
|
self.config.top_p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reasoning_effort(&self) -> Option<String> {
|
||||||
|
self.config.reasoning_effort.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn enabled_tools(&self) -> Option<Vec<String>> {
|
fn enabled_tools(&self) -> Option<Vec<String>> {
|
||||||
None
|
self.enabled_tools.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enabled_mcp_servers(&self) -> Option<Vec<String>> {
|
fn enabled_mcp_servers(&self) -> Option<Vec<String>> {
|
||||||
@@ -596,19 +612,18 @@ impl RoleLike for Agent {
|
|||||||
self.config.top_p = value;
|
self.config.top_p = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_reasoning_effort(&mut self, value: Option<String>) {
|
||||||
|
self.config.reasoning_effort = value;
|
||||||
|
}
|
||||||
|
|
||||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
||||||
match value {
|
self.enabled_tools = value.map(|tools| {
|
||||||
Some(tools) => {
|
tools
|
||||||
self.config.global_tools = tools
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| v.trim().to_string())
|
.map(|v| v.trim().to_string())
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>()
|
||||||
}
|
});
|
||||||
None => {
|
|
||||||
self.config.global_tools.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
||||||
@@ -637,11 +652,15 @@ pub struct AgentConfig {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub top_p: Option<f64>,
|
pub top_p: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reasoning_effort: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub agent_session: Option<String>,
|
pub agent_session: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub auto_continue: bool,
|
pub auto_continue: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub can_spawn_agents: bool,
|
pub can_spawn_agents: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub spawnable_agents: Option<Vec<String>>,
|
||||||
#[serde(default = "default_max_concurrent_agents")]
|
#[serde(default = "default_max_concurrent_agents")]
|
||||||
pub max_concurrent_agents: usize,
|
pub max_concurrent_agents: usize,
|
||||||
#[serde(default = "default_max_agent_depth")]
|
#[serde(default = "default_max_agent_depth")]
|
||||||
@@ -732,6 +751,7 @@ impl AgentConfig {
|
|||||||
model_id: graph.model.clone(),
|
model_id: graph.model.clone(),
|
||||||
temperature: graph.temperature,
|
temperature: graph.temperature,
|
||||||
top_p: graph.top_p,
|
top_p: graph.top_p,
|
||||||
|
reasoning_effort: graph.reasoning_effort.clone(),
|
||||||
description: graph.description.clone(),
|
description: graph.description.clone(),
|
||||||
global_tools: graph.global_tools.clone(),
|
global_tools: graph.global_tools.clone(),
|
||||||
mcp_servers: graph.mcp_servers.clone(),
|
mcp_servers: graph.mcp_servers.clone(),
|
||||||
@@ -766,6 +786,9 @@ impl AgentConfig {
|
|||||||
if let Some(v) = read_env_value::<f64>(&with_prefix("top_p")) {
|
if let Some(v) = read_env_value::<f64>(&with_prefix("top_p")) {
|
||||||
self.top_p = v;
|
self.top_p = v;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) {
|
||||||
|
self.reasoning_effort = v;
|
||||||
|
}
|
||||||
if let Ok(v) = env::var(with_prefix("global_tools"))
|
if let Ok(v) = env::var(with_prefix("global_tools"))
|
||||||
&& let Ok(v) = serde_json::from_str(&v)
|
&& let Ok(v) = serde_json::from_str(&v)
|
||||||
{
|
{
|
||||||
@@ -776,6 +799,11 @@ impl AgentConfig {
|
|||||||
{
|
{
|
||||||
self.mcp_servers = v;
|
self.mcp_servers = v;
|
||||||
}
|
}
|
||||||
|
if let Ok(v) = env::var(with_prefix("spawnable_agents"))
|
||||||
|
&& let Ok(v) = serde_json::from_str(&v)
|
||||||
|
{
|
||||||
|
self.spawnable_agents = Some(v);
|
||||||
|
}
|
||||||
if let Some(v) = read_env_value::<String>(&with_prefix("agent_session")) {
|
if let Some(v) = read_env_value::<String>(&with_prefix("agent_session")) {
|
||||||
self.agent_session = v;
|
self.agent_session = v;
|
||||||
}
|
}
|
||||||
@@ -999,6 +1027,36 @@ pub fn list_agents() -> Vec<String> {
|
|||||||
agents
|
agents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_agents_with_descriptions() -> Vec<(String, String)> {
|
||||||
|
list_agents()
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let description = load_agent_description(&name);
|
||||||
|
(name, description)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AgentMetadataStub {
|
||||||
|
#[serde(default)]
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_agent_description(name: &str) -> String {
|
||||||
|
if let Ok(config) = AgentConfig::load(&paths::agent_config_file(name)) {
|
||||||
|
return config.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(contents) = read_to_string(paths::agent_graph_file(name))
|
||||||
|
&& let Ok(meta) = serde_yaml::from_str::<AgentMetadataStub>(&contents)
|
||||||
|
{
|
||||||
|
return meta.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>)> {
|
pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>)> {
|
||||||
let config_path = paths::agent_config_file(agent_name);
|
let config_path = paths::agent_config_file(agent_name);
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
@@ -1188,4 +1246,44 @@ variables:
|
|||||||
assert_eq!(config.max_agent_depth, default_max_agent_depth());
|
assert_eq!(config.max_agent_depth, default_max_agent_depth());
|
||||||
assert_eq!(config.escalation_timeout, default_escalation_timeout());
|
assert_eq!(config.escalation_timeout, default_escalation_timeout());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_metadata_stub_extracts_description_from_graph_yaml() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: librarian
|
||||||
|
description: External-reference research agent.
|
||||||
|
version: "1.0"
|
||||||
|
start: triage
|
||||||
|
nodes: {}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(meta.description, "External-reference research agent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_metadata_stub_extracts_multiline_description() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: coder
|
||||||
|
description: |
|
||||||
|
Implementation agent. Plans, implements, and runs build + tests in a
|
||||||
|
bounded fix-loop until verified.
|
||||||
|
version: "1.0"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
assert!(meta.description.starts_with("Implementation agent."));
|
||||||
|
assert!(meta.description.contains("bounded fix-loop"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_metadata_stub_defaults_when_description_missing() {
|
||||||
|
let yaml = "name: nameless\nversion: \"1.0\"\n";
|
||||||
|
|
||||||
|
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(meta.description, "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-11
@@ -1,4 +1,4 @@
|
|||||||
use crate::client::{ClientConfig, list_models};
|
use crate::client::{ClientConfig, Model, ModelType, list_models};
|
||||||
use crate::render::{MarkdownRender, RenderOptions};
|
use crate::render::{MarkdownRender, RenderOptions};
|
||||||
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name};
|
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name};
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ pub struct AppConfig {
|
|||||||
pub model_id: String,
|
pub model_id: String,
|
||||||
pub temperature: Option<f64>,
|
pub temperature: Option<f64>,
|
||||||
pub top_p: Option<f64>,
|
pub top_p: Option<f64>,
|
||||||
|
pub reasoning_effort: Option<String>,
|
||||||
|
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
pub stream: bool,
|
pub stream: bool,
|
||||||
@@ -68,6 +69,9 @@ pub struct AppConfig {
|
|||||||
pub memory_cap_with_tools: Option<usize>,
|
pub memory_cap_with_tools: Option<usize>,
|
||||||
pub memory_cap_without_tools: Option<usize>,
|
pub memory_cap_without_tools: Option<usize>,
|
||||||
|
|
||||||
|
pub workspace_instructions: Option<bool>,
|
||||||
|
pub workspace_instructions_files: Option<Vec<String>>,
|
||||||
|
|
||||||
pub rag_embedding_model: Option<String>,
|
pub rag_embedding_model: Option<String>,
|
||||||
pub rag_reranker_model: Option<String>,
|
pub rag_reranker_model: Option<String>,
|
||||||
pub rag_top_k: usize,
|
pub rag_top_k: usize,
|
||||||
@@ -82,12 +86,14 @@ pub struct AppConfig {
|
|||||||
pub document_loaders: HashMap<String, String>,
|
pub document_loaders: HashMap<String, String>,
|
||||||
|
|
||||||
pub highlight: bool,
|
pub highlight: bool,
|
||||||
|
pub raw_markdown: bool,
|
||||||
pub theme: Option<String>,
|
pub theme: Option<String>,
|
||||||
pub left_prompt: Option<String>,
|
pub left_prompt: Option<String>,
|
||||||
pub right_prompt: Option<String>,
|
pub right_prompt: Option<String>,
|
||||||
|
|
||||||
pub user_agent: Option<String>,
|
pub user_agent: Option<String>,
|
||||||
pub save_shell_history: bool,
|
pub save_shell_history: bool,
|
||||||
|
pub no_workspace_mcp: bool,
|
||||||
pub sync_models_url: Option<String>,
|
pub sync_models_url: Option<String>,
|
||||||
|
|
||||||
pub clients: Vec<ClientConfig>,
|
pub clients: Vec<ClientConfig>,
|
||||||
@@ -99,6 +105,7 @@ impl Default for AppConfig {
|
|||||||
model_id: Default::default(),
|
model_id: Default::default(),
|
||||||
temperature: None,
|
temperature: None,
|
||||||
top_p: None,
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
stream: true,
|
stream: true,
|
||||||
@@ -143,6 +150,9 @@ impl Default for AppConfig {
|
|||||||
memory_cap_with_tools: None,
|
memory_cap_with_tools: None,
|
||||||
memory_cap_without_tools: None,
|
memory_cap_without_tools: None,
|
||||||
|
|
||||||
|
workspace_instructions: None,
|
||||||
|
workspace_instructions_files: None,
|
||||||
|
|
||||||
rag_embedding_model: None,
|
rag_embedding_model: None,
|
||||||
rag_reranker_model: None,
|
rag_reranker_model: None,
|
||||||
rag_top_k: 5,
|
rag_top_k: 5,
|
||||||
@@ -156,12 +166,14 @@ impl Default for AppConfig {
|
|||||||
document_loaders: Default::default(),
|
document_loaders: Default::default(),
|
||||||
|
|
||||||
highlight: true,
|
highlight: true,
|
||||||
|
raw_markdown: false,
|
||||||
theme: None,
|
theme: None,
|
||||||
left_prompt: None,
|
left_prompt: None,
|
||||||
right_prompt: None,
|
right_prompt: None,
|
||||||
|
|
||||||
user_agent: None,
|
user_agent: None,
|
||||||
save_shell_history: true,
|
save_shell_history: true,
|
||||||
|
no_workspace_mcp: false,
|
||||||
sync_models_url: None,
|
sync_models_url: None,
|
||||||
|
|
||||||
clients: vec![],
|
clients: vec![],
|
||||||
@@ -175,6 +187,7 @@ impl AppConfig {
|
|||||||
model_id: config.model_id,
|
model_id: config.model_id,
|
||||||
temperature: config.temperature,
|
temperature: config.temperature,
|
||||||
top_p: config.top_p,
|
top_p: config.top_p,
|
||||||
|
reasoning_effort: None,
|
||||||
|
|
||||||
dry_run: config.dry_run,
|
dry_run: config.dry_run,
|
||||||
stream: config.stream,
|
stream: config.stream,
|
||||||
@@ -219,6 +232,9 @@ impl AppConfig {
|
|||||||
memory_cap_with_tools: config.memory_cap_with_tools,
|
memory_cap_with_tools: config.memory_cap_with_tools,
|
||||||
memory_cap_without_tools: config.memory_cap_without_tools,
|
memory_cap_without_tools: config.memory_cap_without_tools,
|
||||||
|
|
||||||
|
workspace_instructions: config.workspace_instructions,
|
||||||
|
workspace_instructions_files: config.workspace_instructions_files,
|
||||||
|
|
||||||
rag_embedding_model: config.rag_embedding_model,
|
rag_embedding_model: config.rag_embedding_model,
|
||||||
rag_reranker_model: config.rag_reranker_model,
|
rag_reranker_model: config.rag_reranker_model,
|
||||||
rag_top_k: config.rag_top_k,
|
rag_top_k: config.rag_top_k,
|
||||||
@@ -232,12 +248,14 @@ impl AppConfig {
|
|||||||
document_loaders: config.document_loaders,
|
document_loaders: config.document_loaders,
|
||||||
|
|
||||||
highlight: config.highlight,
|
highlight: config.highlight,
|
||||||
|
raw_markdown: config.raw_markdown,
|
||||||
theme: config.theme,
|
theme: config.theme,
|
||||||
left_prompt: config.left_prompt,
|
left_prompt: config.left_prompt,
|
||||||
right_prompt: config.right_prompt,
|
right_prompt: config.right_prompt,
|
||||||
|
|
||||||
user_agent: config.user_agent,
|
user_agent: config.user_agent,
|
||||||
save_shell_history: config.save_shell_history,
|
save_shell_history: config.save_shell_history,
|
||||||
|
no_workspace_mcp: false,
|
||||||
sync_models_url: config.sync_models_url,
|
sync_models_url: config.sync_models_url,
|
||||||
|
|
||||||
clients: config.clients,
|
clients: config.clients,
|
||||||
@@ -250,6 +268,7 @@ impl AppConfig {
|
|||||||
app_config.setup_document_loaders();
|
app_config.setup_document_loaders();
|
||||||
app_config.setup_user_agent();
|
app_config.setup_user_agent();
|
||||||
app_config.resolve_model()?;
|
app_config.resolve_model()?;
|
||||||
|
app_config.validate_reasoning_effort()?;
|
||||||
Ok(app_config)
|
Ok(app_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +289,31 @@ impl AppConfig {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_reasoning_effort(&self) -> Result<()> {
|
||||||
|
let Some(ref effort) = self.reasoning_effort else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let model = Model::retrieve_model(self, &self.model_id, ModelType::Chat)?;
|
||||||
|
let levels = model.reasoning_levels();
|
||||||
|
|
||||||
|
if levels.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"reasoning_effort '{}' is configured but the model does not support reasoning effort",
|
||||||
|
effort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !levels.iter().any(|l| l == effort) {
|
||||||
|
bail!(
|
||||||
|
"reasoning_effort '{}' is not valid for the model. Supported levels: {}",
|
||||||
|
effort,
|
||||||
|
levels.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_model(&mut self) -> Result<()> {
|
pub fn resolve_model(&mut self) -> Result<()> {
|
||||||
if self.model_id.is_empty() {
|
if self.model_id.is_empty() {
|
||||||
let models = list_models(self, crate::client::ModelType::Chat);
|
let models = list_models(self, crate::client::ModelType::Chat);
|
||||||
@@ -288,7 +332,7 @@ impl AppConfig {
|
|||||||
return path.clone();
|
return path.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(translated) = paths::translate_sandboxed_home_path(path)
|
if let Some(translated) = paths::translate_sandboxed_home_dir(path)
|
||||||
&& translated.exists()
|
&& translated.exists()
|
||||||
{
|
{
|
||||||
info!(
|
info!(
|
||||||
@@ -308,16 +352,18 @@ impl AppConfig {
|
|||||||
|
|
||||||
pub fn editor(&self) -> Result<String> {
|
pub fn editor(&self) -> Result<String> {
|
||||||
super::EDITOR.get_or_init(move || {
|
super::EDITOR.get_or_init(move || {
|
||||||
let editor = self.editor.clone()
|
if let Some(editor) = self.editor.clone()
|
||||||
.or_else(|| env::var("VISUAL").ok().or_else(|| env::var("EDITOR").ok()))
|
.or_else(|| env::var("VISUAL").ok().or_else(|| env::var("EDITOR").ok()))
|
||||||
.unwrap_or_else(|| {
|
&& which::which(&editor).is_ok()
|
||||||
if cfg!(windows) {
|
{
|
||||||
|
return Some(editor);
|
||||||
|
}
|
||||||
|
let default = if cfg!(windows) {
|
||||||
"notepad".to_string()
|
"notepad".to_string()
|
||||||
} else {
|
} else {
|
||||||
"nano".to_string()
|
"nano".to_string()
|
||||||
}
|
};
|
||||||
});
|
which::which(&default).ok().map(|_| default)
|
||||||
which::which(&editor).ok().map(|_| editor)
|
|
||||||
})
|
})
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable."))
|
.ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable."))
|
||||||
@@ -337,7 +383,7 @@ impl AppConfig {
|
|||||||
let theme = if self.highlight {
|
let theme = if self.highlight {
|
||||||
let theme_mode = if self.light_theme() { "light" } else { "dark" };
|
let theme_mode = if self.light_theme() { "light" } else { "dark" };
|
||||||
let theme_filename = format!("{theme_mode}.tmTheme");
|
let theme_filename = format!("{theme_mode}.tmTheme");
|
||||||
let theme_path = paths::local_path(&theme_filename);
|
let theme_path = paths::local_dir(&theme_filename);
|
||||||
if theme_path.exists() {
|
if theme_path.exists() {
|
||||||
let theme = ThemeSet::get_theme(&theme_path)
|
let theme = ThemeSet::get_theme(&theme_path)
|
||||||
.with_context(|| format!("Invalid theme at '{}'", theme_path.display()))?;
|
.with_context(|| format!("Invalid theme at '{}'", theme_path.display()))?;
|
||||||
@@ -362,14 +408,26 @@ impl AppConfig {
|
|||||||
env::var("COLORTERM").as_ref().map(|v| v.as_str()),
|
env::var("COLORTERM").as_ref().map(|v| v.as_str()),
|
||||||
Ok("truecolor")
|
Ok("truecolor")
|
||||||
);
|
);
|
||||||
Ok(RenderOptions::new(theme, wrap, self.wrap_code, truecolor))
|
Ok(RenderOptions::new(
|
||||||
|
theme,
|
||||||
|
wrap,
|
||||||
|
self.wrap_code,
|
||||||
|
self.raw_markdown,
|
||||||
|
truecolor,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_markdown(&self, text: &str) -> Result<()> {
|
pub fn print_markdown(&self, text: &str) -> Result<()> {
|
||||||
if *IS_STDOUT_TERMINAL {
|
if *IS_STDOUT_TERMINAL {
|
||||||
let render_options = self.render_options()?;
|
let render_options = self.render_options()?;
|
||||||
let mut markdown_render = MarkdownRender::init(render_options)?;
|
let mut markdown_render = MarkdownRender::init(render_options)?;
|
||||||
println!("{}", markdown_render.render(text));
|
let body = markdown_render.render(text);
|
||||||
|
let tail = markdown_render.finalize();
|
||||||
|
if tail.is_empty() {
|
||||||
|
println!("{body}");
|
||||||
|
} else {
|
||||||
|
println!("{body}\n{tail}");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("{text}");
|
println!("{text}");
|
||||||
}
|
}
|
||||||
@@ -421,6 +479,9 @@ impl AppConfig {
|
|||||||
if let Some(v) = super::read_env_value::<f64>(&get_env_name("top_p")) {
|
if let Some(v) = super::read_env_value::<f64>(&get_env_name("top_p")) {
|
||||||
self.top_p = v;
|
self.top_p = v;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = super::read_env_value::<String>(&get_env_name("reasoning_effort")) {
|
||||||
|
self.reasoning_effort = v;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(Some(v)) = super::read_env_bool(&get_env_name("dry_run")) {
|
if let Some(Some(v)) = super::read_env_bool(&get_env_name("dry_run")) {
|
||||||
self.dry_run = v;
|
self.dry_run = v;
|
||||||
@@ -543,6 +604,9 @@ impl AppConfig {
|
|||||||
if *NO_COLOR {
|
if *NO_COLOR {
|
||||||
self.highlight = false;
|
self.highlight = false;
|
||||||
}
|
}
|
||||||
|
if let Some(Some(v)) = super::read_env_bool(&get_env_name("raw_markdown")) {
|
||||||
|
self.raw_markdown = v;
|
||||||
|
}
|
||||||
if self.highlight && self.theme.is_none() {
|
if self.highlight && self.theme.is_none() {
|
||||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
|
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
|
||||||
self.theme = v;
|
self.theme = v;
|
||||||
|
|||||||
@@ -253,6 +253,10 @@ impl Input {
|
|||||||
patch_messages(&mut messages, model);
|
patch_messages(&mut messages, model);
|
||||||
model.guard_max_input_tokens(&messages)?;
|
model.guard_max_input_tokens(&messages)?;
|
||||||
let (temperature, top_p) = (self.role().temperature(), self.role().top_p());
|
let (temperature, top_p) = (self.role().temperature(), self.role().top_p());
|
||||||
|
let reasoning_effort = self
|
||||||
|
.role()
|
||||||
|
.reasoning_effort()
|
||||||
|
.or_else(|| model.default_reasoning_effort().map(|s| s.to_string()));
|
||||||
let functions = if model.supports_function_calling() {
|
let functions = if model.supports_function_calling() {
|
||||||
let fns = self.functions.clone();
|
let fns = self.functions.clone();
|
||||||
if let Some(vec) = &fns {
|
if let Some(vec) = &fns {
|
||||||
@@ -268,6 +272,7 @@ impl Input {
|
|||||||
messages,
|
messages,
|
||||||
temperature,
|
temperature,
|
||||||
top_p,
|
top_p,
|
||||||
|
reasoning_effort,
|
||||||
functions,
|
functions,
|
||||||
stream,
|
stream,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
spawn_mcp_server,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::Result;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -111,10 +111,10 @@ impl McpFactory {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if is_auth_required_error(&e) {
|
if is_auth_required_error(&e) {
|
||||||
anyhow!(
|
e.context(format!(
|
||||||
"MCP server '{name}' requires OAuth authentication. \
|
"MCP server '{name}' requires OAuth authentication. \
|
||||||
Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate."
|
Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate."
|
||||||
)
|
))
|
||||||
} else {
|
} else {
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-45
@@ -7,41 +7,27 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
|
GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
|
||||||
WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths,
|
WORKSPACE_COYOTE_DIR_NAME, paths,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
|
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
|
||||||
pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000;
|
pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WorkspaceMemory {
|
pub struct WorkspaceMemory {
|
||||||
Structured {
|
pub workspace_root: PathBuf,
|
||||||
workspace_root: PathBuf,
|
pub dir: PathBuf,
|
||||||
dir: PathBuf,
|
|
||||||
},
|
|
||||||
Lite {
|
|
||||||
workspace_root: PathBuf,
|
|
||||||
file: PathBuf,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
|
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
|
||||||
for dir in start.ancestors() {
|
for dir in start.ancestors() {
|
||||||
let structured = dir.join(WORKSPACE_MEMORY_DIR_NAME).join(MEMORY_DIR_NAME);
|
let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME);
|
||||||
if structured.join(MEMORY_INDEX_FILE_NAME).exists() {
|
if structured.join(MEMORY_INDEX_FILE_NAME).exists() {
|
||||||
return Some(WorkspaceMemory::Structured {
|
return Some(WorkspaceMemory {
|
||||||
workspace_root: dir.to_path_buf(),
|
workspace_root: dir.to_path_buf(),
|
||||||
dir: structured,
|
dir: structured,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let lite = dir.join(WORKSPACE_MEMORY_FILE_NAME);
|
|
||||||
if lite.exists() {
|
|
||||||
return Some(WorkspaceMemory::Lite {
|
|
||||||
workspace_root: dir.to_path_buf(),
|
|
||||||
file: lite,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -82,10 +68,10 @@ pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
|
|||||||
Ok(mem_dir)
|
Ok(mem_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
|
pub fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
|
||||||
let gitignore = git_root.join(GITIGNORE_FILE_NAME);
|
let gitignore = git_root.join(GITIGNORE_FILE_NAME);
|
||||||
let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/");
|
let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/");
|
||||||
let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}");
|
let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}");
|
||||||
|
|
||||||
let existing = fs::read_to_string(&gitignore).unwrap_or_default();
|
let existing = fs::read_to_string(&gitignore).unwrap_or_default();
|
||||||
let already_present = existing.lines().any(|line| {
|
let already_present = existing.lines().any(|line| {
|
||||||
@@ -212,9 +198,8 @@ impl MemoryStore {
|
|||||||
pub fn load_workspace_index(&self) -> Result<Option<String>> {
|
pub fn load_workspace_index(&self) -> Result<Option<String>> {
|
||||||
match &self.workspace {
|
match &self.workspace {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)),
|
Some(ws) => {
|
||||||
Some(WorkspaceMemory::Structured { dir, .. }) => {
|
let index = ws.dir.join(MEMORY_INDEX_FILE_NAME);
|
||||||
let index = dir.join(MEMORY_INDEX_FILE_NAME);
|
|
||||||
if index.exists() {
|
if index.exists() {
|
||||||
Ok(Some(fs::read_to_string(index)?))
|
Ok(Some(fs::read_to_string(index)?))
|
||||||
} else {
|
} else {
|
||||||
@@ -231,8 +216,8 @@ impl MemoryStore {
|
|||||||
collect_md_files(&self.global_dir, &mut out)?;
|
collect_md_files(&self.global_dir, &mut out)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace {
|
if let Some(ws) = &self.workspace {
|
||||||
collect_md_files(dir, &mut out)?;
|
collect_md_files(&ws.dir, &mut out)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -347,7 +332,7 @@ mod tests {
|
|||||||
let root = temp_root("phase1");
|
let root = temp_root("phase1");
|
||||||
let workspace = root.join("workspace");
|
let workspace = root.join("workspace");
|
||||||
let workspace_memory_dir = workspace
|
let workspace_memory_dir = workspace
|
||||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
.join(WORKSPACE_COYOTE_DIR_NAME)
|
||||||
.join(MEMORY_DIR_NAME);
|
.join(MEMORY_DIR_NAME);
|
||||||
fs::create_dir_all(&workspace_memory_dir).unwrap();
|
fs::create_dir_all(&workspace_memory_dir).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -378,18 +363,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_discovery_prefers_structured_over_lite() {
|
fn workspace_discovery_ignores_root_instructions_file() {
|
||||||
let root = temp_root("prefer");
|
let root = temp_root("no_lite");
|
||||||
let workspace = root.join("ws");
|
let workspace = root.join("ws");
|
||||||
let structured = workspace
|
fs::create_dir_all(&workspace).unwrap();
|
||||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
|
||||||
.join(MEMORY_DIR_NAME);
|
|
||||||
fs::create_dir_all(&structured).unwrap();
|
|
||||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap();
|
|
||||||
fs::write(workspace.join(WORKSPACE_MEMORY_FILE_NAME), "l").unwrap();
|
|
||||||
|
|
||||||
let found = discover_workspace_memory(&workspace);
|
assert!(discover_workspace_memory(&workspace).is_none());
|
||||||
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
@@ -415,7 +395,7 @@ mod tests {
|
|||||||
let root = temp_root("indexes_only");
|
let root = temp_root("indexes_only");
|
||||||
let workspace = root.join("ws");
|
let workspace = root.join("ws");
|
||||||
let structured = workspace
|
let structured = workspace
|
||||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
.join(WORKSPACE_COYOTE_DIR_NAME)
|
||||||
.join(MEMORY_DIR_NAME);
|
.join(MEMORY_DIR_NAME);
|
||||||
fs::create_dir_all(&structured).unwrap();
|
fs::create_dir_all(&structured).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -450,7 +430,7 @@ mod tests {
|
|||||||
let root = temp_root("drill_bodies");
|
let root = temp_root("drill_bodies");
|
||||||
let workspace = root.join("ws");
|
let workspace = root.join("ws");
|
||||||
let structured = workspace
|
let structured = workspace
|
||||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
.join(WORKSPACE_COYOTE_DIR_NAME)
|
||||||
.join(MEMORY_DIR_NAME);
|
.join(MEMORY_DIR_NAME);
|
||||||
fs::create_dir_all(&structured).unwrap();
|
fs::create_dir_all(&structured).unwrap();
|
||||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
||||||
@@ -485,7 +465,7 @@ mod tests {
|
|||||||
let root = temp_root("cap");
|
let root = temp_root("cap");
|
||||||
let workspace = root.join("ws");
|
let workspace = root.join("ws");
|
||||||
let structured = workspace
|
let structured = workspace
|
||||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
.join(WORKSPACE_COYOTE_DIR_NAME)
|
||||||
.join(MEMORY_DIR_NAME);
|
.join(MEMORY_DIR_NAME);
|
||||||
fs::create_dir_all(&structured).unwrap();
|
fs::create_dir_all(&structured).unwrap();
|
||||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
||||||
@@ -575,15 +555,15 @@ mod tests {
|
|||||||
let root = temp_root("walk_up");
|
let root = temp_root("walk_up");
|
||||||
let workspace = root.join("ws");
|
let workspace = root.join("ws");
|
||||||
let mem_dir = workspace
|
let mem_dir = workspace
|
||||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
.join(WORKSPACE_COYOTE_DIR_NAME)
|
||||||
.join(MEMORY_DIR_NAME);
|
.join(MEMORY_DIR_NAME);
|
||||||
fs::create_dir_all(&mem_dir).unwrap();
|
fs::create_dir_all(&mem_dir).unwrap();
|
||||||
fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
||||||
let nested = workspace.join("src").join("deep").join("path");
|
let nested = workspace.join("src").join("deep").join("path");
|
||||||
fs::create_dir_all(&nested).unwrap();
|
fs::create_dir_all(&nested).unwrap();
|
||||||
|
|
||||||
let found = discover_workspace_memory(&nested);
|
let found = discover_workspace_memory(&nested).expect("workspace memory should be found");
|
||||||
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
|
assert_eq!(found.dir, mem_dir);
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-7
@@ -3,6 +3,7 @@ mod app_config;
|
|||||||
mod app_state;
|
mod app_state;
|
||||||
mod input;
|
mod input;
|
||||||
mod install_remote;
|
mod install_remote;
|
||||||
|
pub(crate) mod instructions;
|
||||||
mod macros;
|
mod macros;
|
||||||
mod mcp_factory;
|
mod mcp_factory;
|
||||||
pub(crate) mod memory;
|
pub(crate) mod memory;
|
||||||
@@ -21,6 +22,7 @@ mod update;
|
|||||||
|
|
||||||
pub use self::agent::{
|
pub use self::agent::{
|
||||||
Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents,
|
Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents,
|
||||||
|
list_agents_with_descriptions,
|
||||||
};
|
};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::app_config::AppConfig;
|
pub use self::app_config::AppConfig;
|
||||||
@@ -33,7 +35,7 @@ pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_
|
|||||||
pub use self::role::{
|
pub use self::role::{
|
||||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
||||||
};
|
};
|
||||||
use self::session::Session;
|
pub use self::session::Session;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::skill::Skill;
|
pub use self::skill::Skill;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -43,7 +45,7 @@ pub use self::skill_registry::SkillRegistry;
|
|||||||
pub use self::update::run_self_update;
|
pub use self::update::run_self_update;
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
||||||
ProviderModels, create_client_config, list_client_types,
|
ProviderModels, create_client_config, list_client_types, oauth,
|
||||||
};
|
};
|
||||||
use crate::function::{FunctionDeclaration, Functions};
|
use crate::function::{FunctionDeclaration, Functions};
|
||||||
use crate::rag::Rag;
|
use crate::rag::Rag;
|
||||||
@@ -62,7 +64,7 @@ use indoc::formatdoc;
|
|||||||
use inquire::{Confirm, Select};
|
use inquire::{Confirm, Select};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
@@ -140,10 +142,10 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
|
|||||||
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
||||||
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
||||||
const MCP_FILE_NAME: &str = "mcp.json";
|
const MCP_FILE_NAME: &str = "mcp.json";
|
||||||
|
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
|
||||||
const MEMORY_DIR_NAME: &str = "memory";
|
const MEMORY_DIR_NAME: &str = "memory";
|
||||||
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
||||||
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
|
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
|
||||||
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
|
|
||||||
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
||||||
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||||
@@ -183,7 +185,7 @@ const SUMMARIZATION_PROMPT: &str =
|
|||||||
const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: ";
|
const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: ";
|
||||||
|
|
||||||
const LEFT_PROMPT: &str = "{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} ";
|
const LEFT_PROMPT: &str = "{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} ";
|
||||||
const RIGHT_PROMPT: &str = "{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}";
|
const RIGHT_PROMPT: &str = "{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}";
|
||||||
|
|
||||||
static EDITOR: OnceLock<Option<String>> = OnceLock::new();
|
static EDITOR: OnceLock<Option<String>> = OnceLock::new();
|
||||||
|
|
||||||
@@ -244,6 +246,9 @@ pub struct Config {
|
|||||||
pub memory_cap_with_tools: Option<usize>,
|
pub memory_cap_with_tools: Option<usize>,
|
||||||
pub memory_cap_without_tools: Option<usize>,
|
pub memory_cap_without_tools: Option<usize>,
|
||||||
|
|
||||||
|
pub workspace_instructions: Option<bool>,
|
||||||
|
pub workspace_instructions_files: Option<Vec<String>>,
|
||||||
|
|
||||||
pub rag_embedding_model: Option<String>,
|
pub rag_embedding_model: Option<String>,
|
||||||
pub rag_reranker_model: Option<String>,
|
pub rag_reranker_model: Option<String>,
|
||||||
pub rag_top_k: usize,
|
pub rag_top_k: usize,
|
||||||
@@ -258,6 +263,7 @@ pub struct Config {
|
|||||||
pub document_loaders: HashMap<String, String>,
|
pub document_loaders: HashMap<String, String>,
|
||||||
|
|
||||||
pub highlight: bool,
|
pub highlight: bool,
|
||||||
|
pub raw_markdown: bool,
|
||||||
pub theme: Option<String>,
|
pub theme: Option<String>,
|
||||||
pub left_prompt: Option<String>,
|
pub left_prompt: Option<String>,
|
||||||
pub right_prompt: Option<String>,
|
pub right_prompt: Option<String>,
|
||||||
@@ -319,6 +325,9 @@ impl Default for Config {
|
|||||||
memory_cap_with_tools: None,
|
memory_cap_with_tools: None,
|
||||||
memory_cap_without_tools: None,
|
memory_cap_without_tools: None,
|
||||||
|
|
||||||
|
workspace_instructions: None,
|
||||||
|
workspace_instructions_files: None,
|
||||||
|
|
||||||
rag_embedding_model: None,
|
rag_embedding_model: None,
|
||||||
rag_reranker_model: None,
|
rag_reranker_model: None,
|
||||||
rag_top_k: 5,
|
rag_top_k: 5,
|
||||||
@@ -332,6 +341,7 @@ impl Default for Config {
|
|||||||
document_loaders: Default::default(),
|
document_loaders: Default::default(),
|
||||||
|
|
||||||
highlight: true,
|
highlight: true,
|
||||||
|
raw_markdown: false,
|
||||||
theme: None,
|
theme: None,
|
||||||
left_prompt: None,
|
left_prompt: None,
|
||||||
right_prompt: None,
|
right_prompt: None,
|
||||||
@@ -474,7 +484,7 @@ fn confirm_asset_overwrite(category: AssetCategory, label: &str, target: &Path)
|
|||||||
pub fn default_sessions_dir() -> PathBuf {
|
pub fn default_sessions_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("sessions_dir")) {
|
match env::var(get_env_name("sessions_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
Err(_) => paths::local_path(SESSIONS_DIR_NAME),
|
Err(_) => paths::local_dir(SESSIONS_DIR_NAME),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +599,18 @@ impl Config {
|
|||||||
})
|
})
|
||||||
.with_context(|| "Failed to load config from str")?;
|
.with_context(|| "Failed to load config from str")?;
|
||||||
|
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for cc in &config.clients {
|
||||||
|
let (name, _, _) = oauth::client_config_info(cc);
|
||||||
|
if !seen.insert(name.to_string()) {
|
||||||
|
bail!(
|
||||||
|
"Duplicate client name '{name}' in config.yaml. \
|
||||||
|
Client names must be unique across all `clients[]` entries \
|
||||||
|
to avoid OAuth token collisions."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+192
-50
@@ -2,10 +2,10 @@ use super::role::Role;
|
|||||||
use super::{
|
use super::{
|
||||||
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
|
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
|
||||||
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
|
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
|
||||||
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME,
|
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME,
|
||||||
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME,
|
MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME,
|
||||||
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME,
|
SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME,
|
||||||
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
|
SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
||||||
};
|
};
|
||||||
use crate::client::ProviderModels;
|
use crate::client::ProviderModels;
|
||||||
use crate::config::REPL_HISTORY_DIR_NAME;
|
use crate::config::REPL_HISTORY_DIR_NAME;
|
||||||
@@ -30,11 +30,11 @@ pub fn config_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn local_path(name: &str) -> PathBuf {
|
pub fn local_dir(name: &str) -> PathBuf {
|
||||||
config_dir().join(name)
|
config_dir().join(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cache_path() -> PathBuf {
|
pub fn cache_dir() -> PathBuf {
|
||||||
if let Ok(v) = env::var(get_env_name("cache_dir")) {
|
if let Ok(v) = env::var(get_env_name("cache_dir")) {
|
||||||
PathBuf::from(v)
|
PathBuf::from(v)
|
||||||
} else if let Ok(v) = env::var("XDG_CACHE_HOME") {
|
} else if let Ok(v) = env::var("XDG_CACHE_HOME") {
|
||||||
@@ -49,7 +49,7 @@ pub fn sandbox_kit_override() -> Option<PathBuf> {
|
|||||||
env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from)
|
env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn translate_sandboxed_home_path(path: &Path) -> Option<PathBuf> {
|
pub fn translate_sandboxed_home_dir(path: &Path) -> Option<PathBuf> {
|
||||||
env::var_os("IS_SANDBOX")?;
|
env::var_os("IS_SANDBOX")?;
|
||||||
|
|
||||||
let s = path.to_str()?;
|
let s = path.to_str()?;
|
||||||
@@ -62,7 +62,7 @@ pub fn translate_sandboxed_home_path(path: &Path) -> Option<PathBuf> {
|
|||||||
return Some(translated);
|
return Some(translated);
|
||||||
}
|
}
|
||||||
|
|
||||||
translate_windows_users_path(s)
|
translate_windows_users_dir(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
|
fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
|
||||||
@@ -83,7 +83,7 @@ fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn translate_windows_users_path(s: &str) -> Option<PathBuf> {
|
fn translate_windows_users_dir(s: &str) -> Option<PathBuf> {
|
||||||
let bytes = s.as_bytes();
|
let bytes = s.as_bytes();
|
||||||
if bytes.len() < 4 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' {
|
if bytes.len() < 4 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' {
|
||||||
return None;
|
return None;
|
||||||
@@ -118,7 +118,7 @@ pub fn global_tools_sbx_mixin_file() -> PathBuf {
|
|||||||
pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
|
pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
|
||||||
for dir in start.ancestors() {
|
for dir in start.ancestors() {
|
||||||
let candidate = dir
|
let candidate = dir
|
||||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
.join(WORKSPACE_COYOTE_DIR_NAME)
|
||||||
.join(SBX_MIXIN_FILE_NAME);
|
.join(SBX_MIXIN_FILE_NAME);
|
||||||
if candidate.exists() {
|
if candidate.exists() {
|
||||||
return Some(candidate);
|
return Some(candidate);
|
||||||
@@ -128,20 +128,20 @@ pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn oauth_tokens_path() -> PathBuf {
|
pub fn oauth_tokens_dir() -> PathBuf {
|
||||||
cache_path().join("oauth")
|
cache_dir().join("oauth")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn token_file(client_name: &str) -> PathBuf {
|
pub fn token_file(client_name: &str) -> PathBuf {
|
||||||
oauth_tokens_path().join(format!("{client_name}_oauth_tokens.json"))
|
oauth_tokens_dir().join(format!("{client_name}_oauth_tokens.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn log_path() -> PathBuf {
|
pub fn log_file() -> PathBuf {
|
||||||
cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
|
cache_dir().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sbx_kit_dir() -> PathBuf {
|
pub fn sbx_kit_dir() -> PathBuf {
|
||||||
cache_path().join(SBX_KIT_DIR_NAME)
|
cache_dir().join(SBX_KIT_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sbx_kit_hash_file() -> PathBuf {
|
pub fn sbx_kit_hash_file() -> PathBuf {
|
||||||
@@ -149,7 +149,7 @@ pub fn sbx_kit_hash_file() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sbx_vault_mixins_dir() -> PathBuf {
|
pub fn sbx_vault_mixins_dir() -> PathBuf {
|
||||||
cache_path().join(SBX_VAULT_MIXINS_DIR_NAME)
|
cache_dir().join(SBX_VAULT_MIXINS_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sbx_vault_mixins_hash_file() -> PathBuf {
|
pub fn sbx_vault_mixins_hash_file() -> PathBuf {
|
||||||
@@ -157,20 +157,20 @@ pub fn sbx_vault_mixins_hash_file() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sbx_mixin_kits_dir() -> PathBuf {
|
pub fn sbx_mixin_kits_dir() -> PathBuf {
|
||||||
cache_path().join(SBX_MIXIN_KITS_DIR_NAME)
|
cache_dir().join(SBX_MIXIN_KITS_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config_file() -> PathBuf {
|
pub fn config_file() -> PathBuf {
|
||||||
match env::var(get_env_name("config_file")) {
|
match env::var(get_env_name("config_file")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
Err(_) => local_path(CONFIG_FILE_NAME),
|
Err(_) => local_dir(CONFIG_FILE_NAME),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn roles_dir() -> PathBuf {
|
pub fn roles_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("roles_dir")) {
|
match env::var(get_env_name("roles_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
Err(_) => local_path(ROLES_DIR_NAME),
|
Err(_) => local_dir(ROLES_DIR_NAME),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ pub fn role_file(name: &str) -> PathBuf {
|
|||||||
pub fn skills_dir() -> PathBuf {
|
pub fn skills_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("skills_dir")) {
|
match env::var(get_env_name("skills_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
Err(_) => local_path(SKILLS_DIR_NAME),
|
Err(_) => local_dir(SKILLS_DIR_NAME),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +193,40 @@ pub fn skill_file(name: &str) -> PathBuf {
|
|||||||
skill_dir(name).join("SKILL.md")
|
skill_dir(name).join("SKILL.md")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn workspace_config_dir() -> PathBuf {
|
||||||
|
let workspace_dir_name = match env::var(get_env_name("workspace_config_dir")) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => WORKSPACE_COYOTE_DIR_NAME.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
env::current_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(workspace_dir_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_skills_dir() -> PathBuf {
|
||||||
|
workspace_config_dir().join(SKILLS_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_skill_file(name: &str) -> PathBuf {
|
||||||
|
workspace_skills_dir().join(name).join("SKILL.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
|
||||||
|
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_mcp_config_file_in(workspace_root: &Path) -> Option<PathBuf> {
|
||||||
|
let dir = workspace_config_dir();
|
||||||
|
[
|
||||||
|
dir.join(MCP_FILE_NAME),
|
||||||
|
dir.join(HIDDEN_MCP_FILE_NAME),
|
||||||
|
workspace_root.join(HIDDEN_MCP_FILE_NAME),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn validate_skill_name(name: &str) -> Result<()> {
|
pub fn validate_skill_name(name: &str) -> Result<()> {
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
bail!("Skill name cannot be empty");
|
bail!("Skill name cannot be empty");
|
||||||
@@ -209,7 +243,7 @@ pub fn validate_skill_name(name: &str) -> Result<()> {
|
|||||||
pub fn macros_dir() -> PathBuf {
|
pub fn macros_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("macros_dir")) {
|
match env::var(get_env_name("macros_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
Err(_) => local_path(MACROS_DIR_NAME),
|
Err(_) => local_dir(MACROS_DIR_NAME),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,21 +254,21 @@ pub fn macro_file(name: &str) -> PathBuf {
|
|||||||
pub fn env_file() -> PathBuf {
|
pub fn env_file() -> PathBuf {
|
||||||
match env::var(get_env_name("env_file")) {
|
match env::var(get_env_name("env_file")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
Err(_) => local_path(ENV_FILE_NAME),
|
Err(_) => local_dir(ENV_FILE_NAME),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rags_dir() -> PathBuf {
|
pub fn rags_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("rags_dir")) {
|
match env::var(get_env_name("rags_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
Err(_) => local_path(RAGS_DIR_NAME),
|
Err(_) => local_dir(RAGS_DIR_NAME),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn functions_dir() -> PathBuf {
|
pub fn functions_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("functions_dir")) {
|
match env::var(get_env_name("functions_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
Err(_) => local_path(FUNCTIONS_DIR_NAME),
|
Err(_) => local_dir(FUNCTIONS_DIR_NAME),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +293,7 @@ pub fn bash_prompt_utils_file() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn agents_data_dir() -> PathBuf {
|
pub fn agents_data_dir() -> PathBuf {
|
||||||
local_path(AGENTS_DIR_NAME)
|
local_dir(AGENTS_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn agent_data_dir(name: &str) -> PathBuf {
|
pub fn agent_data_dir(name: &str) -> PathBuf {
|
||||||
@@ -305,25 +339,29 @@ pub fn agent_functions_file(name: &str) -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn models_override_file() -> PathBuf {
|
pub fn models_override_file() -> PathBuf {
|
||||||
local_path("models-override.yaml")
|
local_dir("models-override.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn global_memory_dir() -> PathBuf {
|
pub fn global_memory_dir() -> PathBuf {
|
||||||
config_dir().join(MEMORY_DIR_NAME)
|
config_dir().join(MEMORY_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn global_memory_index_path() -> PathBuf {
|
pub fn global_memory_index_file() -> PathBuf {
|
||||||
global_memory_dir().join(MEMORY_INDEX_FILE_NAME)
|
global_memory_dir().join(MEMORY_INDEX_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
|
pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
|
||||||
workspace_root
|
workspace_root
|
||||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
.join(WORKSPACE_COYOTE_DIR_NAME)
|
||||||
.join(MEMORY_DIR_NAME)
|
.join(MEMORY_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn workspace_memory_index_file_for(workspace_root: &Path) -> PathBuf {
|
||||||
|
workspace_memory_dir_for(workspace_root).join(MEMORY_INDEX_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn repl_history_dir() -> PathBuf {
|
pub fn repl_history_dir() -> PathBuf {
|
||||||
cache_path().join(REPL_HISTORY_DIR_NAME)
|
cache_dir().join(REPL_HISTORY_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
|
pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
|
||||||
@@ -346,7 +384,7 @@ pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
|
|||||||
});
|
});
|
||||||
let resolved_log_path = match env::var(get_env_name("log_path")) {
|
let resolved_log_path = match env::var(get_env_name("log_path")) {
|
||||||
Ok(v) => Some(PathBuf::from(v)),
|
Ok(v) => Some(PathBuf::from(v)),
|
||||||
Err(_) => Some(log_path()),
|
Err(_) => Some(log_file()),
|
||||||
};
|
};
|
||||||
Ok((log_level, resolved_log_path))
|
Ok((log_level, resolved_log_path))
|
||||||
}
|
}
|
||||||
@@ -405,25 +443,31 @@ pub fn has_macro(name: &str) -> bool {
|
|||||||
|
|
||||||
pub fn list_skills() -> Vec<String> {
|
pub fn list_skills() -> Vec<String> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
if let Ok(rd) = read_dir(skills_dir()) {
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
|
for dir in [workspace_skills_dir(), skills_dir()] {
|
||||||
|
if let Ok(rd) = read_dir(dir) {
|
||||||
for entry in rd.flatten() {
|
for entry in rd.flatten() {
|
||||||
if let Ok(file_type) = entry.file_type()
|
if let Ok(file_type) = entry.file_type()
|
||||||
&& file_type.is_dir()
|
&& file_type.is_dir()
|
||||||
&& let Some(name) = entry.file_name().to_str()
|
&& let Some(name) = entry.file_name().to_str()
|
||||||
|
&& !seen.contains(name)
|
||||||
&& entry.path().join("SKILL.md").is_file()
|
&& entry.path().join("SKILL.md").is_file()
|
||||||
&& validate_skill_name(name).is_ok()
|
&& validate_skill_name(name).is_ok()
|
||||||
{
|
{
|
||||||
|
seen.insert(name.to_string());
|
||||||
names.push(name.to_string());
|
names.push(name.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
names.sort_unstable();
|
names.sort_unstable();
|
||||||
names
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_skill(name: &str) -> bool {
|
pub fn has_skill(name: &str) -> bool {
|
||||||
skill_file(name).is_file()
|
workspace_skill_file(name).is_file() || skill_file(name).is_file()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn local_models_override() -> Result<Vec<ProviderModels>> {
|
pub fn local_models_override() -> Result<Vec<ProviderModels>> {
|
||||||
@@ -527,7 +571,7 @@ mod tests {
|
|||||||
fn returns_none_when_not_in_sandbox() {
|
fn returns_none_when_not_in_sandbox() {
|
||||||
without_sandbox(|| {
|
without_sandbox(|| {
|
||||||
let p = Path::new("/home/atusa/.coyote_password");
|
let p = Path::new("/home/atusa/.coyote_password");
|
||||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +581,7 @@ mod tests {
|
|||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("/home/atusa/.coyote_password");
|
let p = Path::new("/home/atusa/.coyote_password");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
translate_sandboxed_home_path(p),
|
translate_sandboxed_home_dir(p),
|
||||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -545,11 +589,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn translates_nested_host_home_path() {
|
fn translates_nested_host_home_dir() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("/home/atusa/.config/coyote/.password");
|
let p = Path::new("/home/atusa/.config/coyote/.password");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
translate_sandboxed_home_path(p),
|
translate_sandboxed_home_dir(p),
|
||||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -560,7 +604,7 @@ mod tests {
|
|||||||
fn returns_none_when_path_already_targets_agent_home() {
|
fn returns_none_when_path_already_targets_agent_home() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("/home/agent/.coyote_password");
|
let p = Path::new("/home/agent/.coyote_password");
|
||||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +613,7 @@ mod tests {
|
|||||||
fn returns_none_when_path_is_outside_home() {
|
fn returns_none_when_path_is_outside_home() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("/etc/coyote/.coyote_password");
|
let p = Path::new("/etc/coyote/.coyote_password");
|
||||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,7 +622,7 @@ mod tests {
|
|||||||
fn returns_none_for_relative_path() {
|
fn returns_none_for_relative_path() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new(".coyote_password");
|
let p = Path::new(".coyote_password");
|
||||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,17 +631,17 @@ mod tests {
|
|||||||
fn returns_none_for_first_segment_not_home() {
|
fn returns_none_for_first_segment_not_home() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("/opt/atusa/.coyote_password");
|
let p = Path::new("/opt/atusa/.coyote_password");
|
||||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn translates_macos_users_path() {
|
fn translates_macos_users_dir() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("/Users/atusa/.coyote_password");
|
let p = Path::new("/Users/atusa/.coyote_password");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
translate_sandboxed_home_path(p),
|
translate_sandboxed_home_dir(p),
|
||||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -605,11 +649,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn translates_macos_nested_path() {
|
fn translates_macos_nested_dir() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("/Users/atusa/.config/coyote/.password");
|
let p = Path::new("/Users/atusa/.config/coyote/.password");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
translate_sandboxed_home_path(p),
|
translate_sandboxed_home_dir(p),
|
||||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -617,10 +661,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn returns_none_when_macos_path_already_targets_agent() {
|
fn returns_none_when_macos_dir_already_targets_agent() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("/Users/agent/.coyote_password");
|
let p = Path::new("/Users/agent/.coyote_password");
|
||||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,7 +674,7 @@ mod tests {
|
|||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("C:\\Users\\atusa\\.coyote_password");
|
let p = Path::new("C:\\Users\\atusa\\.coyote_password");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
translate_sandboxed_home_path(p),
|
translate_sandboxed_home_dir(p),
|
||||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -642,7 +686,7 @@ mod tests {
|
|||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password");
|
let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
translate_sandboxed_home_path(p),
|
translate_sandboxed_home_dir(p),
|
||||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -653,7 +697,105 @@ mod tests {
|
|||||||
fn returns_none_when_windows_path_already_targets_agent() {
|
fn returns_none_when_windows_path_already_targets_agent() {
|
||||||
with_sandbox(|| {
|
with_sandbox(|| {
|
||||||
let p = Path::new("C:\\Users\\agent\\.coyote_password");
|
let p = Path::new("C:\\Users\\agent\\.coyote_password");
|
||||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
assert_eq!(translate_sandboxed_home_dir(p), None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod workspace_mcp_resolution {
|
||||||
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
fn with_workspace_dir<F: FnOnce(&Path, &Path)>(f: F) {
|
||||||
|
let unique = time::SystemTime::now()
|
||||||
|
.duration_since(time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let root = env::temp_dir().join(format!("coyote-workspace-mcp-test-{unique}"));
|
||||||
|
let ws_dir = root.join(WORKSPACE_COYOTE_DIR_NAME);
|
||||||
|
fs::create_dir_all(&ws_dir).unwrap();
|
||||||
|
let env_name = get_env_name("workspace_config_dir");
|
||||||
|
let prev = env::var_os(&env_name);
|
||||||
|
unsafe {
|
||||||
|
env::set_var(&env_name, &ws_dir);
|
||||||
|
}
|
||||||
|
f(&root, &ws_dir);
|
||||||
|
unsafe {
|
||||||
|
match prev {
|
||||||
|
Some(v) => env::set_var(&env_name, v),
|
||||||
|
None => env::remove_var(&env_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn returns_none_when_no_config_exists() {
|
||||||
|
with_workspace_dir(|root, _| {
|
||||||
|
assert_eq!(workspace_mcp_config_file_in(root), None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn finds_mcp_json() {
|
||||||
|
with_workspace_dir(|root, ws_dir| {
|
||||||
|
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
workspace_mcp_config_file_in(root),
|
||||||
|
Some(ws_dir.join("mcp.json"))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn falls_back_to_claude_style_hidden_mcp_json() {
|
||||||
|
with_workspace_dir(|root, ws_dir| {
|
||||||
|
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
workspace_mcp_config_file_in(root),
|
||||||
|
Some(ws_dir.join(".mcp.json"))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn prefers_mcp_json_when_both_exist() {
|
||||||
|
with_workspace_dir(|root, ws_dir| {
|
||||||
|
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
|
||||||
|
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
workspace_mcp_config_file_in(root),
|
||||||
|
Some(ws_dir.join("mcp.json"))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn falls_back_to_project_root_hidden_mcp_json() {
|
||||||
|
with_workspace_dir(|root, _| {
|
||||||
|
fs::write(root.join(".mcp.json"), "{}").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
workspace_mcp_config_file_in(root),
|
||||||
|
Some(root.join(".mcp.json"))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn prefers_workspace_dir_config_over_project_root() {
|
||||||
|
with_workspace_dir(|root, ws_dir| {
|
||||||
|
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||||
|
fs::write(root.join(".mcp.json"), "{}").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
workspace_mcp_config_file_in(root),
|
||||||
|
Some(ws_dir.join(".mcp.json"))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ pub(in crate::config) const DEFAULT_SPAWN_INSTRUCTIONS: &str = indoc! {"
|
|||||||
| `agent__spawn` | Spawn a subagent in the background. Returns an `id` immediately. |
|
| `agent__spawn` | Spawn a subagent in the background. Returns an `id` immediately. |
|
||||||
| `agent__check` | Non-blocking check: is the agent done yet? Returns PENDING or result. |
|
| `agent__check` | Non-blocking check: is the agent done yet? Returns PENDING or result. |
|
||||||
| `agent__collect` | Blocking wait: wait for an agent to finish, return its output. |
|
| `agent__collect` | Blocking wait: wait for an agent to finish, return its output. |
|
||||||
| `agent__list` | List all spawned agents and their status. |
|
| `agent__list_available` | List all agent types you can spawn (name + description). Use this to discover specialists before calling `agent__spawn`. |
|
||||||
|
| `agent__list_running` | List all subagents YOU have spawned, with their status. |
|
||||||
| `agent__cancel` | Cancel a running agent by ID. |
|
| `agent__cancel` | Cancel a running agent by ID. |
|
||||||
| `agent__task_create` | Create a task in the dependency-aware task queue. |
|
| `agent__task_create` | Create a task in the dependency-aware task queue. |
|
||||||
| `agent__task_list` | List all tasks and their status/dependencies. |
|
| `agent__task_list` | List all tasks and their status/dependencies. |
|
||||||
|
|||||||
+941
-21
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 enabled_mcp_servers(&self) -> Option<Vec<String>>;
|
||||||
fn set_model(&mut self, model: Model);
|
fn set_model(&mut self, model: Model);
|
||||||
fn set_temperature(&mut self, value: Option<f64>);
|
fn set_temperature(&mut self, value: Option<f64>);
|
||||||
|
fn reasoning_effort(&self) -> Option<String>;
|
||||||
fn set_top_p(&mut self, value: Option<f64>);
|
fn set_top_p(&mut self, value: Option<f64>);
|
||||||
|
fn set_reasoning_effort(&mut self, value: Option<String>);
|
||||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
|
fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
|
||||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>);
|
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>);
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,8 @@ pub struct Role {
|
|||||||
temperature: Option<f64>,
|
temperature: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
top_p: Option<f64>,
|
top_p: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
reasoning_effort: Option<String>,
|
||||||
#[serde(
|
#[serde(
|
||||||
default,
|
default,
|
||||||
skip_serializing_if = "Option::is_none",
|
skip_serializing_if = "Option::is_none",
|
||||||
@@ -116,6 +120,9 @@ impl Role {
|
|||||||
"model" => role.model_id = value.as_str().map(|v| v.to_string()),
|
"model" => role.model_id = value.as_str().map(|v| v.to_string()),
|
||||||
"temperature" => role.temperature = value.as_f64(),
|
"temperature" => role.temperature = value.as_f64(),
|
||||||
"top_p" => role.top_p = value.as_f64(),
|
"top_p" => role.top_p = value.as_f64(),
|
||||||
|
"reasoning_effort" => {
|
||||||
|
role.reasoning_effort = value.as_str().map(|v| v.to_string())
|
||||||
|
}
|
||||||
"enabled_tools" => role.enabled_tools = parse_string_or_array(value),
|
"enabled_tools" => role.enabled_tools = parse_string_or_array(value),
|
||||||
"enabled_mcp_servers" => {
|
"enabled_mcp_servers" => {
|
||||||
role.enabled_mcp_servers = parse_string_or_array(value)
|
role.enabled_mcp_servers = parse_string_or_array(value)
|
||||||
@@ -170,6 +177,9 @@ impl Role {
|
|||||||
if let Some(top_p) = self.top_p() {
|
if let Some(top_p) = self.top_p() {
|
||||||
metadata.push(format!("top_p: {top_p}"));
|
metadata.push(format!("top_p: {top_p}"));
|
||||||
}
|
}
|
||||||
|
if let Some(reasoning_effort) = self.reasoning_effort() {
|
||||||
|
metadata.push(format!("reasoning_effort: {reasoning_effort}"));
|
||||||
|
}
|
||||||
if let Some(enabled_tools) = &self.enabled_tools {
|
if let Some(enabled_tools) = &self.enabled_tools {
|
||||||
let inline = serde_json::to_string(enabled_tools).unwrap_or_else(|_| "[]".to_string());
|
let inline = serde_json::to_string(enabled_tools).unwrap_or_else(|_| "[]".to_string());
|
||||||
metadata.push(format!("enabled_tools: {inline}"));
|
metadata.push(format!("enabled_tools: {inline}"));
|
||||||
@@ -245,12 +255,14 @@ impl Role {
|
|||||||
|
|
||||||
pub fn sync<T: RoleLike>(&mut self, role_like: &T) {
|
pub fn sync<T: RoleLike>(&mut self, role_like: &T) {
|
||||||
let model = role_like.model();
|
let model = role_like.model();
|
||||||
|
let reasoning_effort = role_like.reasoning_effort();
|
||||||
let temperature = role_like.temperature();
|
let temperature = role_like.temperature();
|
||||||
let top_p = role_like.top_p();
|
let top_p = role_like.top_p();
|
||||||
let enabled_tools = role_like.enabled_tools();
|
let enabled_tools = role_like.enabled_tools();
|
||||||
let enabled_mcp_servers = role_like.enabled_mcp_servers();
|
let enabled_mcp_servers = role_like.enabled_mcp_servers();
|
||||||
self.batch_set(
|
self.batch_set(
|
||||||
model,
|
model,
|
||||||
|
reasoning_effort,
|
||||||
temperature,
|
temperature,
|
||||||
top_p,
|
top_p,
|
||||||
enabled_tools,
|
enabled_tools,
|
||||||
@@ -261,12 +273,16 @@ impl Role {
|
|||||||
pub fn batch_set(
|
pub fn batch_set(
|
||||||
&mut self,
|
&mut self,
|
||||||
model: &Model,
|
model: &Model,
|
||||||
|
reasoning_effort: Option<String>,
|
||||||
temperature: Option<f64>,
|
temperature: Option<f64>,
|
||||||
top_p: Option<f64>,
|
top_p: Option<f64>,
|
||||||
enabled_tools: Option<Vec<String>>,
|
enabled_tools: Option<Vec<String>>,
|
||||||
enabled_mcp_servers: Option<Vec<String>>,
|
enabled_mcp_servers: Option<Vec<String>>,
|
||||||
) {
|
) {
|
||||||
self.set_model(model.clone());
|
self.set_model(model.clone());
|
||||||
|
if reasoning_effort.is_some() {
|
||||||
|
self.set_reasoning_effort(reasoning_effort.clone());
|
||||||
|
}
|
||||||
if temperature.is_some() {
|
if temperature.is_some() {
|
||||||
self.set_temperature(temperature);
|
self.set_temperature(temperature);
|
||||||
}
|
}
|
||||||
@@ -410,6 +426,10 @@ impl RoleLike for Role {
|
|||||||
self.top_p
|
self.top_p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reasoning_effort(&self) -> Option<String> {
|
||||||
|
self.reasoning_effort.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn enabled_tools(&self) -> Option<Vec<String>> {
|
fn enabled_tools(&self) -> Option<Vec<String>> {
|
||||||
self.enabled_tools.clone()
|
self.enabled_tools.clone()
|
||||||
}
|
}
|
||||||
@@ -433,6 +453,10 @@ impl RoleLike for Role {
|
|||||||
self.top_p = value;
|
self.top_p = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_reasoning_effort(&mut self, value: Option<String>) {
|
||||||
|
self.reasoning_effort = value;
|
||||||
|
}
|
||||||
|
|
||||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
||||||
self.enabled_tools = value;
|
self.enabled_tools = value;
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-6
@@ -24,6 +24,8 @@ pub struct Session {
|
|||||||
temperature: Option<f64>,
|
temperature: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
top_p: Option<f64>,
|
top_p: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
reasoning_effort: Option<String>,
|
||||||
#[serde(
|
#[serde(
|
||||||
default,
|
default,
|
||||||
skip_serializing_if = "Option::is_none",
|
skip_serializing_if = "Option::is_none",
|
||||||
@@ -175,6 +177,14 @@ impl Session {
|
|||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_name(&mut self, name: String) {
|
||||||
|
self.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_autoname(&mut self) {
|
||||||
|
self.autoname = None;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn role_name(&self) -> Option<&str> {
|
pub fn role_name(&self) -> Option<&str> {
|
||||||
self.role_name.as_deref()
|
self.role_name.as_deref()
|
||||||
}
|
}
|
||||||
@@ -261,7 +271,7 @@ impl Session {
|
|||||||
data["messages"] = json!(self.messages);
|
data["messages"] = json!(self.messages);
|
||||||
|
|
||||||
let output = serde_yaml::to_string(&data)
|
let output = serde_yaml::to_string(&data)
|
||||||
.with_context(|| format!("Unable to show info about session '{}'", &self.name))?;
|
.with_context(|| format!("Unable to show info about session '{}'", self.name))?;
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,14 +368,24 @@ impl Session {
|
|||||||
for message in &self.messages {
|
for message in &self.messages {
|
||||||
match message.role {
|
match message.role {
|
||||||
MessageRole::System => {
|
MessageRole::System => {
|
||||||
lines.push(
|
let body = render
|
||||||
render
|
.render(&message.content.render_input(resolve_url_fn, agent_info));
|
||||||
.render(&message.content.render_input(resolve_url_fn, agent_info)),
|
let tail = render.finalize();
|
||||||
);
|
if tail.is_empty() {
|
||||||
|
lines.push(body);
|
||||||
|
} else {
|
||||||
|
lines.push(format!("{body}\n{tail}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MessageRole::Assistant => {
|
MessageRole::Assistant => {
|
||||||
if let MessageContent::Text(text) = &message.content {
|
if let MessageContent::Text(text) = &message.content {
|
||||||
lines.push(render.render(text));
|
let body = render.render(text);
|
||||||
|
let tail = render.finalize();
|
||||||
|
if tail.is_empty() {
|
||||||
|
lines.push(body);
|
||||||
|
} else {
|
||||||
|
lines.push(format!("{body}\n{tail}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lines.push("".into());
|
lines.push("".into());
|
||||||
}
|
}
|
||||||
@@ -401,6 +421,7 @@ impl Session {
|
|||||||
self.model_id = role.model().id();
|
self.model_id = role.model().id();
|
||||||
self.temperature = role.temperature();
|
self.temperature = role.temperature();
|
||||||
self.top_p = role.top_p();
|
self.top_p = role.top_p();
|
||||||
|
self.reasoning_effort = role.reasoning_effort();
|
||||||
self.enabled_tools = role.enabled_tools();
|
self.enabled_tools = role.enabled_tools();
|
||||||
self.enabled_mcp_servers = role.enabled_mcp_servers();
|
self.enabled_mcp_servers = role.enabled_mcp_servers();
|
||||||
self.model = role.model().clone();
|
self.model = role.model().clone();
|
||||||
@@ -732,6 +753,15 @@ impl Session {
|
|||||||
self.update_tokens();
|
self.update_tokens();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn pop_last_exchange(&mut self) -> Option<String> {
|
||||||
|
let user_idx = self.messages.iter().rposition(|m| m.role.is_user())?;
|
||||||
|
let user_text = self.messages[user_idx].content.as_text()?.to_string();
|
||||||
|
self.messages.truncate(user_idx);
|
||||||
|
self.dirty = true;
|
||||||
|
self.update_tokens();
|
||||||
|
Some(user_text)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn echo_messages(&self, input: &Input) -> String {
|
pub fn echo_messages(&self, input: &Input) -> String {
|
||||||
let messages = self.build_messages(input);
|
let messages = self.build_messages(input);
|
||||||
serde_yaml::to_string(&messages).unwrap_or_else(|_| "Unable to echo message".into())
|
serde_yaml::to_string(&messages).unwrap_or_else(|_| "Unable to echo message".into())
|
||||||
@@ -783,6 +813,10 @@ impl RoleLike for Session {
|
|||||||
self.top_p
|
self.top_p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reasoning_effort(&self) -> Option<String> {
|
||||||
|
self.reasoning_effort.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn enabled_tools(&self) -> Option<Vec<String>> {
|
fn enabled_tools(&self) -> Option<Vec<String>> {
|
||||||
self.enabled_tools.clone()
|
self.enabled_tools.clone()
|
||||||
}
|
}
|
||||||
@@ -814,6 +848,13 @@ impl RoleLike for Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_reasoning_effort(&mut self, value: Option<String>) {
|
||||||
|
if self.reasoning_effort != value {
|
||||||
|
self.reasoning_effort = value;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
||||||
if self.enabled_tools != value {
|
if self.enabled_tools != value {
|
||||||
self.enabled_tools = value;
|
self.enabled_tools = value;
|
||||||
@@ -1023,4 +1064,13 @@ mod tests {
|
|||||||
session.set_autonaming(true);
|
session.set_autonaming(true);
|
||||||
assert!(!session.need_autoname());
|
assert!(!session.need_autoname());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_set_name_updates_name() {
|
||||||
|
let mut session = Session::default();
|
||||||
|
|
||||||
|
session.set_name("my-fork".to_string());
|
||||||
|
|
||||||
|
assert_eq!(session.name(), "my-fork");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -117,7 +117,11 @@ impl Skill {
|
|||||||
|
|
||||||
pub fn load(name: &str) -> Result<Self> {
|
pub fn load(name: &str) -> Result<Self> {
|
||||||
paths::validate_skill_name(name)?;
|
paths::validate_skill_name(name)?;
|
||||||
let path = paths::skill_file(name);
|
let path = if paths::workspace_skill_file(name).is_file() {
|
||||||
|
paths::workspace_skill_file(name)
|
||||||
|
} else {
|
||||||
|
paths::skill_file(name)
|
||||||
|
};
|
||||||
let content = read_to_string(&path)
|
let content = read_to_string(&path)
|
||||||
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
|
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
|
||||||
Ok(Skill::new(name, &content))
|
Ok(Skill::new(name, &content))
|
||||||
|
|||||||
+19
-33
@@ -321,7 +321,7 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
|
|||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"files": entries,
|
"files": entries,
|
||||||
"global_index_exists": paths::global_memory_index_path().exists(),
|
"global_index_exists": paths::global_memory_index_file().exists(),
|
||||||
"workspace": store.workspace.as_ref().map(workspace_label),
|
"workspace": store.workspace.as_ref().map(workspace_label),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -474,7 +474,7 @@ fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value>
|
|||||||
let description = renamed.frontmatter.description.clone().unwrap_or_default();
|
let description = renamed.frontmatter.description.clone().unwrap_or_default();
|
||||||
ensure_index_entry(&index_path, &new_name, &description)?;
|
ensure_index_entry(&index_path, &new_name, &description)?;
|
||||||
|
|
||||||
// Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only.
|
// Other indexes (other scope's MEMORY.md): rewrite wikilinks only.
|
||||||
for other_index in other_index_paths(store, &target_dir) {
|
for other_index in other_index_paths(store, &target_dir) {
|
||||||
if let Ok(existing) = fs::read_to_string(&other_index)
|
if let Ok(existing) = fs::read_to_string(&other_index)
|
||||||
&& existing.contains(&needle)
|
&& existing.contains(&needle)
|
||||||
@@ -539,18 +539,12 @@ fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec<PathBuf> {
|
|||||||
out.push(global_index);
|
out.push(global_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
match &store.workspace {
|
if let Some(ws) = &store.workspace {
|
||||||
Some(WorkspaceMemory::Structured { dir, .. }) => {
|
let index = ws.dir.join("MEMORY.md");
|
||||||
let index = dir.join("MEMORY.md");
|
if ws.dir.as_path() != own_dir && index.exists() {
|
||||||
if dir.as_path() != own_dir && index.exists() {
|
|
||||||
out.push(index);
|
out.push(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => {
|
|
||||||
out.push(file.clone());
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -637,10 +631,7 @@ fn find_file(store: &MemoryStore, name: &str) -> Result<Option<MemoryFile>> {
|
|||||||
|
|
||||||
fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
|
fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
|
||||||
match &store.workspace {
|
match &store.workspace {
|
||||||
Some(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()),
|
Some(ws) => Ok(ws.dir.clone()),
|
||||||
Some(WorkspaceMemory::Lite { workspace_root, .. }) => {
|
|
||||||
Ok(paths::workspace_memory_dir_for(workspace_root))
|
|
||||||
}
|
|
||||||
None => match find_git_root(cwd) {
|
None => match find_git_root(cwd) {
|
||||||
Some(git_root) => bootstrap_workspace_memory(&git_root),
|
Some(git_root) => bootstrap_workspace_memory(&git_root),
|
||||||
None => bail!(
|
None => bail!(
|
||||||
@@ -652,20 +643,10 @@ fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_label(w: &WorkspaceMemory) -> Value {
|
fn workspace_label(w: &WorkspaceMemory) -> Value {
|
||||||
match w {
|
json!({
|
||||||
WorkspaceMemory::Structured { workspace_root, .. } => json!({
|
"root": w.workspace_root.display().to_string(),
|
||||||
"mode": "structured",
|
"dir": w.dir.display().to_string(),
|
||||||
"root": workspace_root.display().to_string(),
|
})
|
||||||
}),
|
|
||||||
WorkspaceMemory::Lite {
|
|
||||||
workspace_root,
|
|
||||||
file,
|
|
||||||
} => json!({
|
|
||||||
"mode": "lite",
|
|
||||||
"root": workspace_root.display().to_string(),
|
|
||||||
"file": file.display().to_string(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
||||||
@@ -872,19 +853,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_write_dir_promotes_lite_to_structured_subdir() {
|
fn workspace_write_dir_treats_root_instructions_file_as_no_memory() {
|
||||||
let root = temp_root("ws_lite_promote");
|
let root = temp_root("ws_instructions_only");
|
||||||
let workspace = root.join("ws");
|
let workspace = root.join("ws");
|
||||||
fs::create_dir_all(&workspace).unwrap();
|
fs::create_dir_all(workspace.join(".git")).unwrap();
|
||||||
fs::write(workspace.join("COYOTE.md"), "lite").unwrap();
|
fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
|
||||||
|
|
||||||
let store = MemoryStore {
|
let store = MemoryStore {
|
||||||
global_dir: root.join("g"),
|
global_dir: root.join("g"),
|
||||||
workspace: discover_workspace_memory(&workspace),
|
workspace: discover_workspace_memory(&workspace),
|
||||||
};
|
};
|
||||||
|
assert!(store.workspace.is_none(), "COYOTE.md must not be memory");
|
||||||
|
|
||||||
let dir = workspace_write_dir(&store, &workspace).unwrap();
|
let dir = workspace_write_dir(&store, &workspace).unwrap();
|
||||||
assert_eq!(dir, workspace.join(".coyote").join("memory"));
|
assert_eq!(dir, workspace.join(".coyote").join("memory"));
|
||||||
|
assert!(
|
||||||
|
dir.join("MEMORY.md").exists(),
|
||||||
|
"bootstrap must create index"
|
||||||
|
);
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-20
@@ -5,6 +5,7 @@ pub(crate) mod todo;
|
|||||||
pub(crate) mod user_interaction;
|
pub(crate) mod user_interaction;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
client::ThinkingBlock,
|
||||||
config::{Agent, RequestContext},
|
config::{Agent, RequestContext},
|
||||||
graph,
|
graph,
|
||||||
utils::*,
|
utils::*,
|
||||||
@@ -144,29 +145,19 @@ pub async fn eval_tool_calls(
|
|||||||
if calls.is_empty() {
|
if calls.is_empty() {
|
||||||
bail!("The request was aborted because an infinite loop of function calls was detected.")
|
bail!("The request was aborted because an infinite loop of function calls was detected.")
|
||||||
}
|
}
|
||||||
let mut is_all_null = true;
|
|
||||||
for call in calls {
|
for call in calls {
|
||||||
if let Some(msg) = ctx.tool_scope.tool_tracker.check_loop(&call.clone()) {
|
if let Some(msg) = ctx.tool_scope.tool_tracker.check_loop(&call.clone()) {
|
||||||
let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", &msg.trim());
|
let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", msg.trim());
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
warning_text(format!("{}: ⚠️ Tool-call loop detected! ⚠️", &call.name).as_str())
|
warning_text(format!("{}: ⚠️ Tool-call loop detected! ⚠️", call.name).as_str())
|
||||||
);
|
);
|
||||||
let val = json!(dup_msg);
|
let val = json!(dup_msg);
|
||||||
output.push(ToolResult::new(call, val));
|
output.push(ToolResult::new(call, val));
|
||||||
is_all_null = false;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut result = call.eval(ctx).await?;
|
let result = call.eval(ctx).await?;
|
||||||
if result.is_null() {
|
output.push(ToolResult::new(call, normalize_tool_result(result)));
|
||||||
result = json!("DONE");
|
|
||||||
} else {
|
|
||||||
is_all_null = false;
|
|
||||||
}
|
|
||||||
output.push(ToolResult::new(call, result));
|
|
||||||
}
|
|
||||||
if is_all_null {
|
|
||||||
output = vec![];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !output.is_empty() {
|
if !output.is_empty() {
|
||||||
@@ -196,15 +187,37 @@ pub async fn eval_tool_calls(
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tools that succeed silently (e.g. `mkdir -p` via execute_command) evaluate to
|
||||||
|
/// `Null`. Substitute a concrete `"DONE"` marker so every call produces a
|
||||||
|
/// `ToolResult`: agentic loops (graph llm nodes, spawned agents, the REPL) treat
|
||||||
|
/// an empty `tool_results` as "the LLM concluded", so dropping silent results
|
||||||
|
/// would prematurely terminate a turn that called only silent tools.
|
||||||
|
fn normalize_tool_result(result: Value) -> Value {
|
||||||
|
if result.is_null() {
|
||||||
|
json!("DONE")
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
pub call: ToolCall,
|
pub call: ToolCall,
|
||||||
pub output: Value,
|
pub output: Value,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub thinking: Vec<ThinkingBlock>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolResult {
|
impl ToolResult {
|
||||||
pub fn new(call: ToolCall, output: Value) -> Self {
|
pub fn new(call: ToolCall, output: Value) -> Self {
|
||||||
Self { call, output }
|
Self {
|
||||||
|
call,
|
||||||
|
output,
|
||||||
|
text: None,
|
||||||
|
thinking: vec![],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +743,7 @@ impl Functions {
|
|||||||
let root_dir = paths::functions_dir();
|
let root_dir = paths::functions_dir();
|
||||||
let tool_path = format!(
|
let tool_path = format!(
|
||||||
"{}/{binary_name}",
|
"{}/{binary_name}",
|
||||||
&paths::global_tools_dir().to_string_lossy()
|
paths::global_tools_dir().to_string_lossy()
|
||||||
);
|
);
|
||||||
content_template
|
content_template
|
||||||
.replace("{function_name}", binary_name)
|
.replace("{function_name}", binary_name)
|
||||||
@@ -741,7 +754,7 @@ impl Functions {
|
|||||||
let root_dir = paths::agent_data_dir(agent_name);
|
let root_dir = paths::agent_data_dir(agent_name);
|
||||||
let tool_path = format!(
|
let tool_path = format!(
|
||||||
"{}/{binary_name}",
|
"{}/{binary_name}",
|
||||||
&paths::global_tools_dir().to_string_lossy()
|
paths::global_tools_dir().to_string_lossy()
|
||||||
);
|
);
|
||||||
content_template
|
content_template
|
||||||
.replace("{function_name}", binary_name)
|
.replace("{function_name}", binary_name)
|
||||||
@@ -870,7 +883,7 @@ impl Functions {
|
|||||||
let root_dir = paths::functions_dir();
|
let root_dir = paths::functions_dir();
|
||||||
let tool_path = format!(
|
let tool_path = format!(
|
||||||
"{}/{binary_name}",
|
"{}/{binary_name}",
|
||||||
&paths::global_tools_dir().to_string_lossy()
|
paths::global_tools_dir().to_string_lossy()
|
||||||
);
|
);
|
||||||
content_template
|
content_template
|
||||||
.replace("{function_name}", binary_name)
|
.replace("{function_name}", binary_name)
|
||||||
@@ -881,7 +894,7 @@ impl Functions {
|
|||||||
let root_dir = paths::agent_data_dir(agent_name);
|
let root_dir = paths::agent_data_dir(agent_name);
|
||||||
let tool_path = format!(
|
let tool_path = format!(
|
||||||
"{}/{binary_name}",
|
"{}/{binary_name}",
|
||||||
&paths::global_tools_dir().to_string_lossy()
|
paths::global_tools_dir().to_string_lossy()
|
||||||
);
|
);
|
||||||
content_template
|
content_template
|
||||||
.replace("{function_name}", binary_name)
|
.replace("{function_name}", binary_name)
|
||||||
@@ -1527,6 +1540,21 @@ mod tests {
|
|||||||
ToolCall::new(name.to_string(), args, Some("id1".to_string()))
|
ToolCall::new(name.to_string(), args, Some("id1".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_tool_result_substitutes_done_for_null() {
|
||||||
|
assert_eq!(normalize_tool_result(Value::Null), json!("DONE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_tool_result_preserves_non_null_values() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_tool_result(json!({"output": "hi"})),
|
||||||
|
json!({"output": "hi"})
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_tool_result(json!("")), json!(""));
|
||||||
|
assert_eq!(normalize_tool_result(json!(false)), json!(false));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn toolcall_new_sets_fields() {
|
fn toolcall_new_sets_fields() {
|
||||||
let tc = ToolCall::new("my_tool".into(), json!({"x": 1}), Some("call-1".into()));
|
let tc = ToolCall::new("my_tool".into(), json!({"x": 1}), Some("call-1".into()));
|
||||||
@@ -1765,7 +1793,8 @@ mod tests {
|
|||||||
assert!(f.contains("agent__spawn"));
|
assert!(f.contains("agent__spawn"));
|
||||||
assert!(f.contains("agent__check"));
|
assert!(f.contains("agent__check"));
|
||||||
assert!(f.contains("agent__collect"));
|
assert!(f.contains("agent__collect"));
|
||||||
assert!(f.contains("agent__list"));
|
assert!(f.contains("agent__list_running"));
|
||||||
|
assert!(f.contains("agent__list_available"));
|
||||||
assert!(f.contains("agent__cancel"));
|
assert!(f.contains("agent__cancel"));
|
||||||
assert!(f.contains("agent__reply_escalation"));
|
assert!(f.contains("agent__reply_escalation"));
|
||||||
}
|
}
|
||||||
@@ -1890,4 +1919,33 @@ mod tests {
|
|||||||
assert_eq!(result.call.name, "my_tool");
|
assert_eq!(result.call.name, "my_tool");
|
||||||
assert_eq!(result.output, json!({"result": "ok"}));
|
assert_eq!(result.output, json!({"result": "ok"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thinking_block_matches_anthropic_wire_format() {
|
||||||
|
let block = ThinkingBlock::Thinking {
|
||||||
|
thinking: "chain of thought".to_string(),
|
||||||
|
signature: "sig123".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&block).unwrap(),
|
||||||
|
json!({"type": "thinking", "thinking": "chain of thought", "signature": "sig123"})
|
||||||
|
);
|
||||||
|
|
||||||
|
let redacted = ThinkingBlock::RedactedThinking {
|
||||||
|
data: "opaque".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&redacted).unwrap(),
|
||||||
|
json!({"type": "redacted_thinking", "data": "opaque"})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_result_deserializes_without_text_and_thinking() {
|
||||||
|
let yaml = "call:\n name: my_tool\n arguments: {}\noutput: ok\n";
|
||||||
|
let result: ToolResult = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert_eq!(result.call.name, "my_tool");
|
||||||
|
assert!(result.text.is_none());
|
||||||
|
assert!(result.thinking.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-14
@@ -1,6 +1,8 @@
|
|||||||
use super::{FunctionDeclaration, JsonSchema};
|
use super::{FunctionDeclaration, JsonSchema};
|
||||||
use crate::client::{Model, ModelType, call_chat_completions};
|
use crate::client::{Model, ModelType, call_chat_completions};
|
||||||
use crate::config::{Agent, AppState, Input, RequestContext, Role, RoleLike};
|
use crate::config::{
|
||||||
|
Agent, AppState, Input, RequestContext, Role, RoleLike, list_agents_with_descriptions,
|
||||||
|
};
|
||||||
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
|
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
|
||||||
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor};
|
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor};
|
||||||
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal};
|
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal};
|
||||||
@@ -23,6 +25,13 @@ pub const SUPERVISOR_FUNCTION_PREFIX: &str = "agent__";
|
|||||||
|
|
||||||
pub const PENDING_AGENTS_GUARDRAIL_MAX: u32 = 3;
|
pub const PENDING_AGENTS_GUARDRAIL_MAX: u32 = 3;
|
||||||
|
|
||||||
|
fn agent_permitted(whitelist: Option<&[String]>, target: &str) -> bool {
|
||||||
|
match whitelist {
|
||||||
|
None => true,
|
||||||
|
Some(w) => w.iter().any(|a| a == target),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub enum GuardrailAction {
|
pub enum GuardrailAction {
|
||||||
NoAction,
|
NoAction,
|
||||||
Inject(String),
|
Inject(String),
|
||||||
@@ -193,8 +202,23 @@ pub fn supervisor_function_declarations() -> Vec<FunctionDeclaration> {
|
|||||||
agent: false,
|
agent: false,
|
||||||
},
|
},
|
||||||
FunctionDeclaration {
|
FunctionDeclaration {
|
||||||
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list"),
|
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list_running"),
|
||||||
description: "List all currently running subagents and their status.".to_string(),
|
description: "List all subagents YOU have spawned that are still tracked by the supervisor, with their \
|
||||||
|
status. Use this to see which of your background agents are still active. To discover which \
|
||||||
|
agent types you can spawn in the first place, use `agent__list_available` instead.".to_string(),
|
||||||
|
parameters: JsonSchema {
|
||||||
|
type_value: Some("object".to_string()),
|
||||||
|
properties: Some(IndexMap::new()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
agent: false,
|
||||||
|
},
|
||||||
|
FunctionDeclaration {
|
||||||
|
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list_available"),
|
||||||
|
description: "List all agent types installed and available to spawn (name + description). Use this to \
|
||||||
|
discover what specialists exist before calling `agent__spawn` — especially when you're unsure \
|
||||||
|
which agent to delegate to. This is the discovery counterpart to `agent__list_running` \
|
||||||
|
(which reports agents you have already spawned).".to_string(),
|
||||||
parameters: JsonSchema {
|
parameters: JsonSchema {
|
||||||
type_value: Some("object".to_string()),
|
type_value: Some("object".to_string()),
|
||||||
properties: Some(IndexMap::new()),
|
properties: Some(IndexMap::new()),
|
||||||
@@ -384,7 +408,8 @@ pub async fn handle_supervisor_tool(
|
|||||||
"spawn" => handle_spawn(ctx, args).await,
|
"spawn" => handle_spawn(ctx, args).await,
|
||||||
"check" => handle_check(ctx, args).await,
|
"check" => handle_check(ctx, args).await,
|
||||||
"collect" => handle_collect(ctx, args).await,
|
"collect" => handle_collect(ctx, args).await,
|
||||||
"list" => handle_list(ctx),
|
"list_running" => handle_list_running(ctx),
|
||||||
|
"list_available" => handle_list_available(ctx),
|
||||||
"cancel" => handle_cancel(ctx, args).await,
|
"cancel" => handle_cancel(ctx, args).await,
|
||||||
"send_message" => handle_send_message(ctx, args),
|
"send_message" => handle_send_message(ctx, args),
|
||||||
"check_inbox" => handle_check_inbox(ctx),
|
"check_inbox" => handle_check_inbox(ctx),
|
||||||
@@ -624,6 +649,18 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
|||||||
.to_string();
|
.to_string();
|
||||||
let _task_id = args.get("task_id").and_then(Value::as_str);
|
let _task_id = args.get("task_id").and_then(Value::as_str);
|
||||||
|
|
||||||
|
if let Some(parent) = ctx.agent.as_ref()
|
||||||
|
&& !agent_permitted(parent.spawnable_agents(), &agent_name)
|
||||||
|
{
|
||||||
|
let whitelist = parent.spawnable_agents().unwrap_or_default();
|
||||||
|
return Ok(json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": format!(
|
||||||
|
"Agent '{agent_name}' is not in this agent's `spawnable_agents` whitelist. Allowed: {whitelist:?}. Call `agent__list_available` to see what you can spawn."
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let short_uuid = &Uuid::new_v4().to_string()[..8];
|
let short_uuid = &Uuid::new_v4().to_string()[..8];
|
||||||
let agent_id = format!("agent_{agent_name}_{short_uuid}");
|
let agent_id = format!("agent_{agent_name}_{short_uuid}");
|
||||||
|
|
||||||
@@ -920,7 +957,7 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_list(ctx: &mut RequestContext) -> Result<Value> {
|
fn handle_list_running(ctx: &mut RequestContext) -> Result<Value> {
|
||||||
let supervisor = ctx
|
let supervisor = ctx
|
||||||
.supervisor
|
.supervisor
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -948,6 +985,35 @@ fn handle_list(ctx: &mut RequestContext) -> Result<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_list_available(ctx: &RequestContext) -> Result<Value> {
|
||||||
|
let whitelist: Option<Vec<String>> = ctx
|
||||||
|
.agent
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|a| a.spawnable_agents())
|
||||||
|
.map(<[String]>::to_vec);
|
||||||
|
|
||||||
|
let entries: Vec<(String, String)> = list_agents_with_descriptions()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(name, _)| agent_permitted(whitelist.as_deref(), name))
|
||||||
|
.collect();
|
||||||
|
let count = entries.len();
|
||||||
|
let agents: Vec<Value> = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, description)| {
|
||||||
|
if description.is_empty() {
|
||||||
|
json!({ "name": name })
|
||||||
|
} else {
|
||||||
|
json!({ "name": name, "description": description })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"count": count,
|
||||||
|
"agents": agents,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||||
let id = args
|
let id = args
|
||||||
.get("id")
|
.get("id")
|
||||||
@@ -1434,32 +1500,75 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_list_empty_supervisor() {
|
fn handle_list_running_empty_supervisor() {
|
||||||
let mut ctx = ctx_with_supervisor(4, 3);
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
let result = handle_list(&mut ctx).unwrap();
|
let result = handle_list_running(&mut ctx).unwrap();
|
||||||
assert_eq!(result["active_count"], 0);
|
assert_eq!(result["active_count"], 0);
|
||||||
assert_eq!(result["max_concurrent"], 4);
|
assert_eq!(result["max_concurrent"], 4);
|
||||||
assert!(result["agents"].as_array().unwrap().is_empty());
|
assert!(result["agents"].as_array().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_list_with_agents() {
|
fn handle_list_running_with_agents() {
|
||||||
let mut ctx = ctx_with_supervisor(4, 3);
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
register_fake_agent(&mut ctx, "a1", "explore");
|
register_fake_agent(&mut ctx, "a1", "explore");
|
||||||
register_fake_agent(&mut ctx, "a2", "coder");
|
register_fake_agent(&mut ctx, "a2", "coder");
|
||||||
let result = handle_list(&mut ctx).unwrap();
|
let result = handle_list_running(&mut ctx).unwrap();
|
||||||
assert_eq!(result["active_count"], 2);
|
assert_eq!(result["active_count"], 2);
|
||||||
let agents = result["agents"].as_array().unwrap();
|
let agents = result["agents"].as_array().unwrap();
|
||||||
assert_eq!(agents.len(), 2);
|
assert_eq!(agents.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_list_no_supervisor_errors() {
|
fn handle_list_running_no_supervisor_errors() {
|
||||||
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
||||||
let result = handle_list(&mut ctx);
|
let result = handle_list_running(&mut ctx);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_list_available_returns_shape() {
|
||||||
|
let ctx = ctx_with_supervisor(4, 3);
|
||||||
|
|
||||||
|
let result = handle_list_available(&ctx).unwrap();
|
||||||
|
|
||||||
|
assert!(result["count"].is_number());
|
||||||
|
assert!(result["agents"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_list_available_unrestricted_when_no_whitelist() {
|
||||||
|
let ctx = ctx_with_supervisor(4, 3);
|
||||||
|
let result = handle_list_available(&ctx).unwrap();
|
||||||
|
|
||||||
|
let full_count = result["count"].as_u64().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(full_count as usize, list_agents_with_descriptions().len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_permitted_none_whitelist_allows_all() {
|
||||||
|
assert!(agent_permitted(None, "explore"));
|
||||||
|
assert!(agent_permitted(None, "anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_permitted_empty_whitelist_denies_all() {
|
||||||
|
let empty: Vec<String> = vec![];
|
||||||
|
|
||||||
|
assert!(!agent_permitted(Some(&empty), "explore"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_permitted_named_whitelist_matches_exact() {
|
||||||
|
let allowed = vec!["explore".to_string(), "coder".to_string()];
|
||||||
|
|
||||||
|
assert!(agent_permitted(Some(&allowed), "explore"));
|
||||||
|
assert!(agent_permitted(Some(&allowed), "coder"));
|
||||||
|
assert!(!agent_permitted(Some(&allowed), "oracle"));
|
||||||
|
assert!(!agent_permitted(Some(&allowed), "Explore"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handle_check_unknown_agent() {
|
fn handle_check_unknown_agent() {
|
||||||
let mut ctx = ctx_with_supervisor(4, 3);
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
@@ -1753,13 +1862,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dispatch_routes_list() {
|
fn dispatch_routes_list_running() {
|
||||||
let mut ctx = ctx_with_supervisor(4, 3);
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
let result =
|
let result = run_async(handle_supervisor_tool(
|
||||||
run_async(handle_supervisor_tool(&mut ctx, "agent__list", &json!({}))).unwrap();
|
&mut ctx,
|
||||||
|
"agent__list_running",
|
||||||
|
&json!({}),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
assert!(result["active_count"].is_number());
|
assert!(result["active_count"].is_number());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_routes_list_available() {
|
||||||
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
|
let result = run_async(handle_supervisor_tool(
|
||||||
|
&mut ctx,
|
||||||
|
"agent__list_available",
|
||||||
|
&json!({}),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(result["count"].is_number());
|
||||||
|
assert!(result["agents"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dispatch_routes_task_list() {
|
fn dispatch_routes_task_list() {
|
||||||
let mut ctx = ctx_with_supervisor(4, 3);
|
let mut ctx = ctx_with_supervisor(4, 3);
|
||||||
|
|||||||
@@ -329,6 +329,9 @@ fn build_inline_role(
|
|||||||
if let Some(p) = node.top_p {
|
if let Some(p) = node.top_p {
|
||||||
role.set_top_p(Some(p));
|
role.set_top_p(Some(p));
|
||||||
}
|
}
|
||||||
|
if let Some(v) = &node.reasoning_effort {
|
||||||
|
role.set_reasoning_effort(Some(v.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
if node.tools.as_deref().unwrap_or_default().is_empty() {
|
if node.tools.as_deref().unwrap_or_default().is_empty() {
|
||||||
role.set_enabled_tools(Some(Vec::new()));
|
role.set_enabled_tools(Some(Vec::new()));
|
||||||
@@ -499,6 +502,7 @@ mod tests {
|
|||||||
model: None,
|
model: None,
|
||||||
temperature: None,
|
temperature: None,
|
||||||
top_p: None,
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
fallback: None,
|
fallback: None,
|
||||||
max_attempts: 1,
|
max_attempts: 1,
|
||||||
max_iterations: 10,
|
max_iterations: 10,
|
||||||
|
|||||||
+25
-4
@@ -33,7 +33,7 @@ async fn extract_via_extractor(
|
|||||||
parent_ctx: &mut RequestContext,
|
parent_ctx: &mut RequestContext,
|
||||||
is_repair: bool,
|
is_repair: bool,
|
||||||
) -> Result<Value> {
|
) -> Result<Value> {
|
||||||
let role = build_extractor_role()?;
|
let role = build_extractor_role(parent_ctx);
|
||||||
let prompt = build_extractor_prompt(raw, schema, is_repair);
|
let prompt = build_extractor_prompt(raw, schema, is_repair);
|
||||||
|
|
||||||
let saved_role = parent_ctx.role.clone();
|
let saved_role = parent_ctx.role.clone();
|
||||||
@@ -53,11 +53,12 @@ async fn extract_via_extractor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_extractor_role() -> Result<Role> {
|
fn build_extractor_role(ctx: &RequestContext) -> Role {
|
||||||
let mut role = Role::new(EXTRACTOR_ROLE_NAME, EXTRACTOR_ROLE_PROMPT);
|
let mut role = Role::new(EXTRACTOR_ROLE_NAME, EXTRACTOR_ROLE_PROMPT);
|
||||||
|
role.set_model(ctx.current_model().clone());
|
||||||
role.set_enabled_tools(Some(Vec::new()));
|
role.set_enabled_tools(Some(Vec::new()));
|
||||||
role.set_enabled_mcp_servers(Some(Vec::new()));
|
role.set_enabled_mcp_servers(Some(Vec::new()));
|
||||||
Ok(role)
|
role
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_extractor_prompt(raw: &str, schema: &Value, is_repair: bool) -> String {
|
fn build_extractor_prompt(raw: &str, schema: &Value, is_repair: bool) -> String {
|
||||||
@@ -107,8 +108,14 @@ fn strip_code_fences(s: &str) -> &str {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::client::Model;
|
||||||
|
use crate::config::{AppState, WorkingMode};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn make_ctx() -> RequestContext {
|
||||||
|
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn try_parse_json_accepts_plain_object() {
|
fn try_parse_json_accepts_plain_object() {
|
||||||
let v = try_parse_json(r#"{"a": 1}"#).unwrap();
|
let v = try_parse_json(r#"{"a": 1}"#).unwrap();
|
||||||
@@ -181,9 +188,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_extractor_role_disables_tools_and_mcp() {
|
fn build_extractor_role_disables_tools_and_mcp() {
|
||||||
let role = build_extractor_role().expect("builtin role must exist");
|
let ctx = make_ctx();
|
||||||
|
|
||||||
|
let role = build_extractor_role(&ctx);
|
||||||
|
|
||||||
assert_eq!(role.enabled_tools().as_deref(), Some([].as_slice()));
|
assert_eq!(role.enabled_tools().as_deref(), Some([].as_slice()));
|
||||||
assert_eq!(role.enabled_mcp_servers().as_deref(), Some([].as_slice()));
|
assert_eq!(role.enabled_mcp_servers().as_deref(), Some([].as_slice()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_extractor_role_uses_parent_context_model() {
|
||||||
|
let mut ctx = make_ctx();
|
||||||
|
let mut parent_role = Role::new("parent", "parent prompt");
|
||||||
|
parent_role.set_model(Model::new("client-x", "model-y"));
|
||||||
|
ctx.role = Some(parent_role);
|
||||||
|
|
||||||
|
let role = build_extractor_role(&ctx);
|
||||||
|
|
||||||
|
assert_eq!(role.model().id(), "client-x:model-y");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ pub struct Graph {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub top_p: Option<f64>,
|
pub top_p: Option<f64>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reasoning_effort: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_tools: Vec<String>,
|
pub global_tools: Vec<String>,
|
||||||
|
|
||||||
@@ -288,6 +291,9 @@ pub struct LlmNode {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub top_p: Option<f64>,
|
pub top_p: Option<f64>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reasoning_effort: Option<String>,
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub fallback: Option<String>,
|
pub fallback: Option<String>,
|
||||||
|
|
||||||
|
|||||||
@@ -946,6 +946,7 @@ mod tests {
|
|||||||
model: None,
|
model: None,
|
||||||
temperature: None,
|
temperature: None,
|
||||||
top_p: None,
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
global_tools: Vec::new(),
|
global_tools: Vec::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
skills_enabled: None,
|
skills_enabled: None,
|
||||||
@@ -1048,6 +1049,7 @@ mod tests {
|
|||||||
model: None,
|
model: None,
|
||||||
temperature: None,
|
temperature: None,
|
||||||
top_p: None,
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
fallback: fallback.map(String::from),
|
fallback: fallback.map(String::from),
|
||||||
max_attempts: 1,
|
max_attempts: 1,
|
||||||
max_iterations: 10,
|
max_iterations: 10,
|
||||||
|
|||||||
+78
-23
@@ -21,12 +21,13 @@ use crate::cli::Cli;
|
|||||||
use crate::client::{
|
use crate::client::{
|
||||||
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
|
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
|
||||||
};
|
};
|
||||||
use crate::config::paths;
|
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
|
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
|
||||||
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
|
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
|
||||||
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
|
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
|
||||||
};
|
};
|
||||||
|
use crate::config::{memory, paths};
|
||||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||||
use crate::mcp::McpServersConfig;
|
use crate::mcp::McpServersConfig;
|
||||||
use crate::render::{prompt_theme, render_error};
|
use crate::render::{prompt_theme, render_error};
|
||||||
@@ -187,7 +188,11 @@ async fn main() -> Result<()> {
|
|||||||
let abort_signal = create_abort_signal();
|
let abort_signal = create_abort_signal();
|
||||||
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
|
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
|
||||||
let cfg = Config::load_with_interpolation(info_flag).await?;
|
let cfg = Config::load_with_interpolation(info_flag).await?;
|
||||||
let app_config: Arc<AppConfig> = Arc::new(AppConfig::from_config(cfg)?);
|
let mut app_config = AppConfig::from_config(cfg)?;
|
||||||
|
if cli.no_workspace_mcp {
|
||||||
|
app_config.no_workspace_mcp = true;
|
||||||
|
}
|
||||||
|
let app_config: Arc<AppConfig> = Arc::new(app_config);
|
||||||
let app_state: Arc<AppState> = Arc::new(
|
let app_state: Arc<AppState> = Arc::new(
|
||||||
AppState::init(
|
AppState::init(
|
||||||
app_config,
|
app_config,
|
||||||
@@ -362,9 +367,21 @@ async fn run(
|
|||||||
if cli.no_stream {
|
if cli.no_stream {
|
||||||
update_app_config(&mut ctx, |app| app.stream = false);
|
update_app_config(&mut ctx, |app| app.stream = false);
|
||||||
}
|
}
|
||||||
|
if cli.raw_markdown {
|
||||||
|
update_app_config(&mut ctx, |app| app.raw_markdown = true);
|
||||||
|
}
|
||||||
if cli.no_memory {
|
if cli.no_memory {
|
||||||
update_app_config(&mut ctx, |app| app.memory = Some(false));
|
update_app_config(&mut ctx, |app| app.memory = Some(false));
|
||||||
}
|
}
|
||||||
|
if cli.no_workspace_instructions {
|
||||||
|
update_app_config(&mut ctx, |app| app.workspace_instructions = Some(false));
|
||||||
|
}
|
||||||
|
if !cli.workspace_instructions_file.is_empty() {
|
||||||
|
let files = cli.workspace_instructions_file.clone();
|
||||||
|
update_app_config(&mut ctx, |app| {
|
||||||
|
app.workspace_instructions_files = Some(files);
|
||||||
|
});
|
||||||
|
}
|
||||||
if cli.empty_session {
|
if cli.empty_session {
|
||||||
ctx.empty_session()?;
|
ctx.empty_session()?;
|
||||||
}
|
}
|
||||||
@@ -374,13 +391,17 @@ async fn run(
|
|||||||
if let Some(scope) = cli.init_memory {
|
if let Some(scope) = cli.init_memory {
|
||||||
let (path, content) = match scope {
|
let (path, content) = match scope {
|
||||||
MemoryScope::Global => (
|
MemoryScope::Global => (
|
||||||
paths::global_memory_index_path(),
|
paths::global_memory_index_file(),
|
||||||
"# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
|
"# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
|
||||||
),
|
),
|
||||||
MemoryScope::Workspace => (
|
MemoryScope::Workspace => {
|
||||||
env::current_dir()?.join("COYOTE.md"),
|
let cwd = env::current_dir()?;
|
||||||
"# Workspace Memory\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n",
|
let root = memory::find_git_root(&cwd).unwrap_or(cwd);
|
||||||
),
|
(
|
||||||
|
paths::workspace_memory_index_file_for(&root),
|
||||||
|
"# Workspace Memory Index\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -393,9 +414,34 @@ async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs::write(&path, content)?;
|
fs::write(&path, content)?;
|
||||||
|
if scope == MemoryScope::Workspace
|
||||||
|
&& let Some(git_root) = memory::find_git_root(&path)
|
||||||
|
{
|
||||||
|
memory::append_gitignore_entry(&git_root)?;
|
||||||
|
}
|
||||||
println!("✓ Created memory marker at '{}'.", path.display());
|
println!("✓ Created memory marker at '{}'.", path.display());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if cli.init_instructions {
|
||||||
|
let path = env::current_dir()?.join(WORKSPACE_INSTRUCTIONS_FILE_NAME);
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"Workspace instructions already exist at '{}'.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
"# Project Instructions\n\n<!-- Human-curated instructions for AI agents working in this repo. -->\n<!-- Coyote injects this file into the system prompt read-only, in full. -->\n",
|
||||||
|
)?;
|
||||||
|
println!("✓ Created workspace instructions at '{}'.", path.display());
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if cli.info {
|
if cli.info {
|
||||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||||
let info = ctx.info(app.as_ref())?;
|
let info = ctx.info(app.as_ref())?;
|
||||||
@@ -559,7 +605,7 @@ async fn shell_execute(
|
|||||||
|
|
||||||
match answer_char {
|
match answer_char {
|
||||||
'e' => {
|
'e' => {
|
||||||
debug!("{} {:?}", shell.cmd, &[&shell.arg, &eval_str]);
|
debug!("{} {:?}", shell.cmd, [&shell.arg, &eval_str]);
|
||||||
let code = run_command(&shell.cmd, &[&shell.arg, &eval_str], None)?;
|
let code = run_command(&shell.cmd, &[&shell.arg, &eval_str], None)?;
|
||||||
if code == 0 && app.save_shell_history {
|
if code == 0 && app.save_shell_history {
|
||||||
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
||||||
@@ -726,28 +772,37 @@ fn resolve_oauth_client(
|
|||||||
explicit: Option<&str>,
|
explicit: Option<&str>,
|
||||||
clients: &[ClientConfig],
|
clients: &[ClientConfig],
|
||||||
) -> Result<(String, Box<dyn OAuthProvider>)> {
|
) -> Result<(String, Box<dyn OAuthProvider>)> {
|
||||||
if let Some(name) = explicit {
|
let find_by_name = |name: &str| -> Option<&ClientConfig> {
|
||||||
let provider_type = oauth::resolve_provider_type(name, clients)
|
clients.iter().find(|cc| {
|
||||||
.ok_or_else(|| anyhow!("Client '{name}' not found or doesn't support OAuth"))?;
|
let (n, _, auth) = oauth::client_config_info(cc);
|
||||||
let provider = oauth::get_oauth_provider(provider_type).unwrap();
|
n == name && auth == Some("oauth")
|
||||||
return Ok((name.to_string(), provider));
|
})
|
||||||
}
|
};
|
||||||
|
|
||||||
|
let 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);
|
let candidates = oauth::list_oauth_capable_clients(clients);
|
||||||
match candidates.len() {
|
match candidates.len() {
|
||||||
0 => bail!("No OAuth-capable clients configured."),
|
0 => bail!("No OAuth-capable clients configured."),
|
||||||
1 => {
|
1 => find_by_name(&candidates[0]).unwrap(),
|
||||||
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 choice =
|
let choice =
|
||||||
Select::new("Select a client to authenticate:", candidates.clone()).prompt()?;
|
Select::new("Select a client to authenticate:", candidates.clone()).prompt()?;
|
||||||
let provider_type = oauth::resolve_provider_type(&choice, clients).unwrap();
|
find_by_name(&choice)
|
||||||
let provider = oauth::get_oauth_provider(provider_type).unwrap();
|
.ok_or_else(|| anyhow!("Selected client '{choice}' not found"))?
|
||||||
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)?;
|
spec.validate(name)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.config = Some(mcp_servers_config);
|
let mut merged = mcp_servers_config;
|
||||||
|
if !app_config.no_workspace_mcp
|
||||||
|
&& let Some(ws_path) = paths::workspace_mcp_config_file()
|
||||||
|
{
|
||||||
|
match tokio::fs::read_to_string(&ws_path).await {
|
||||||
|
Ok(ws_content) if !ws_content.trim().is_empty() => {
|
||||||
|
match interpolate_secrets(&ws_content, vault) {
|
||||||
|
Ok((parsed, missing)) if missing.is_empty() => {
|
||||||
|
match serde_json::from_str::<McpServersConfig>(&parsed) {
|
||||||
|
Ok(ws_config) => {
|
||||||
|
let mut loaded = Vec::new();
|
||||||
|
for (name, spec) in ws_config.mcp_servers {
|
||||||
|
match spec.validate(&name) {
|
||||||
|
Ok(_) => {
|
||||||
|
loaded.push(name.clone());
|
||||||
|
merged.mcp_servers.insert(name, spec);
|
||||||
|
}
|
||||||
|
Err(e) => warn!(
|
||||||
|
"Invalid workspace MCP server '{name}': {e}. Skipping."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !loaded.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"Loading workspace MCP servers: {}",
|
||||||
|
loaded.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse workspace MCP config: {e}. Skipping.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((_, missing)) => warn!(
|
||||||
|
"Workspace MCP config references missing vault secrets: {missing:?}. Skipping."
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to process workspace MCP config: {e}. Skipping.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry.config = Some(merged);
|
||||||
|
|
||||||
if start_mcp_servers && app_config.mcp_server_support {
|
if start_mcp_servers && app_config.mcp_server_support {
|
||||||
abortable_run_with_spinner(
|
abortable_run_with_spinner(
|
||||||
@@ -1016,4 +1061,14 @@ mod tests {
|
|||||||
|
|
||||||
assert!(!is_auth_required_error(&e));
|
assert!(!is_auth_required_error(&e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_auth_required_error_survives_context_wrapping() {
|
||||||
|
let e = anyhow!("Auth required, when send initialize request").context(
|
||||||
|
"MCP server 'github' requires OAuth authentication. \
|
||||||
|
Run `coyote --auth-mcp github` or `.mcp auth github` in the REPL to authenticate.",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(is_auth_required_error(&e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-24
@@ -14,6 +14,8 @@ use url::Url;
|
|||||||
struct ProtectedResourceMetadata {
|
struct ProtectedResourceMetadata {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
authorization_servers: Vec<String>,
|
authorization_servers: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
scopes_supported: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -59,8 +61,8 @@ impl OAuthProvider for McpOAuthProvider {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scopes(&self) -> &str {
|
fn scopes(&self) -> String {
|
||||||
&self.scopes
|
self.scopes.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_request_format(&self) -> TokenRequestFormat {
|
fn token_request_format(&self) -> TokenRequestFormat {
|
||||||
@@ -140,7 +142,7 @@ fn mcp_token_key(server_name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_registered_client_id(server_name: &str) -> Option<String> {
|
fn load_registered_client_id(server_name: &str) -> Option<String> {
|
||||||
let path = paths::oauth_tokens_path().join(format!("mcp_{server_name}_registration.json"));
|
let path = paths::oauth_tokens_dir().join(format!("mcp_{server_name}_registration.json"));
|
||||||
let content = fs::read_to_string(path).ok()?;
|
let content = fs::read_to_string(path).ok()?;
|
||||||
let reg: McpRegistration = serde_json::from_str(&content).ok()?;
|
let reg: McpRegistration = serde_json::from_str(&content).ok()?;
|
||||||
|
|
||||||
@@ -148,7 +150,7 @@ fn load_registered_client_id(server_name: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
|
fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
|
||||||
let dir = paths::oauth_tokens_path();
|
let dir = paths::oauth_tokens_dir();
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
let path = dir.join(format!("mcp_{server_name}_registration.json"));
|
let path = dir.join(format!("mcp_{server_name}_registration.json"));
|
||||||
@@ -187,46 +189,122 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
||||||
let base = extract_base_url(server_url)?;
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
let mut tried: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// RFC 9728: try protected resource metadata first; it points to the auth server
|
// RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose
|
||||||
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
|
// WWW-Authenticate challenge advertises the protected resource metadata URL.
|
||||||
if let Ok(resp) = client.get(&pr_url).send().await
|
let mut pr_urls = Vec::new();
|
||||||
&& resp.status().is_success()
|
if let Some(url) = probe_resource_metadata_url(&client, server_url).await {
|
||||||
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
|
pr_urls.push(url);
|
||||||
&& let Some(auth_server) = pr.authorization_servers.first()
|
}
|
||||||
{
|
|
||||||
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
|
// RFC 9728 @ 3.1: path-aware well-known URL, then root as legacy fallback.
|
||||||
|
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
|
if let Ok(resp) = client.get(&as_url).send().await
|
||||||
&& resp.status().is_success()
|
&& resp.status().is_success()
|
||||||
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
|
&& let Ok(mut meta) = resp.json::<OAuthServerMetadata>().await
|
||||||
{
|
{
|
||||||
|
// Some auth servers (e.g. GitHub) omit scopes_supported from
|
||||||
|
// their metadata; fall back to the resource's advertised scopes.
|
||||||
|
if meta.scopes_supported.is_empty() {
|
||||||
|
meta.scopes_supported = pr.scopes_supported.clone();
|
||||||
|
}
|
||||||
return Ok(meta);
|
return Ok(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let as_url = format!("{base}/.well-known/oauth-authorization-server");
|
// Last resort: the MCP server itself may host authorization server metadata.
|
||||||
let resp = client
|
for as_url in well_known_urls(server_url, "oauth-authorization-server")? {
|
||||||
.get(&as_url)
|
tried.push(as_url.clone());
|
||||||
.send()
|
if let Ok(resp) = client.get(&as_url).send().await
|
||||||
.await
|
&& resp.status().is_success()
|
||||||
.with_context(|| format!("Failed to reach {as_url}"))?;
|
{
|
||||||
|
|
||||||
if resp.status().is_success() {
|
|
||||||
return resp
|
return resp
|
||||||
.json::<OAuthServerMetadata>()
|
.json::<OAuthServerMetadata>()
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"Could not discover OAuth metadata for '{server_url}'.\n\
|
"Could not discover OAuth metadata for '{server_url}'.\n\
|
||||||
Tried:\n {pr_url}\n {as_url}\n\
|
Tried:\n {}\n\
|
||||||
Ensure the server supports MCP OAuth discovery, or consult its documentation."
|
Ensure the server supports MCP OAuth discovery, or consult its documentation.",
|
||||||
|
tried.join("\n ")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probes the MCP server with an unauthenticated request and extracts the
|
||||||
|
/// `resource_metadata` URL from the 401 `WWW-Authenticate` challenge (RFC 9728 @ 5.1).
|
||||||
|
async fn probe_resource_metadata_url(client: &Client, server_url: &str) -> Option<String> {
|
||||||
|
let resp = client.get(server_url).send().await.ok()?;
|
||||||
|
let header = resp.headers().get(reqwest::header::WWW_AUTHENTICATE)?;
|
||||||
|
|
||||||
|
parse_resource_metadata(header.to_str().ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the `resource_metadata` parameter value from a `WWW-Authenticate`
|
||||||
|
/// challenge, e.g. `Bearer error="...", resource_metadata="https://..."`.
|
||||||
|
fn parse_resource_metadata(challenge: &str) -> Option<String> {
|
||||||
|
let (_, rest) = challenge.split_once("resource_metadata=")?;
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let value = if let Some(stripped) = rest.strip_prefix('"') {
|
||||||
|
stripped.split('"').next()?
|
||||||
|
} else {
|
||||||
|
rest.split([',', ' ']).next()?
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds candidate well-known metadata URLs for `url`, ordered by spec preference:
|
||||||
|
/// 1. Path-aware (RFC 8414 @ 3.1 / RFC 9728 @ 3.1): `{origin}/.well-known/{suffix}{path}`
|
||||||
|
/// 2. Legacy appended form: `{url}/.well-known/{suffix}`
|
||||||
|
/// 3. Root: `{origin}/.well-known/{suffix}`
|
||||||
|
///
|
||||||
|
/// URLs without a path component yield only the root form.
|
||||||
|
fn well_known_urls(url: &str, suffix: &str) -> Result<Vec<String>> {
|
||||||
|
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||||
|
let origin = extract_base_url(url)?;
|
||||||
|
let path = parsed.path().trim_end_matches('/');
|
||||||
|
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
if !path.is_empty() && path != "/" {
|
||||||
|
urls.push(format!("{origin}/.well-known/{suffix}{path}"));
|
||||||
|
urls.push(format!("{origin}{path}/.well-known/{suffix}"));
|
||||||
|
}
|
||||||
|
urls.push(format!("{origin}/.well-known/{suffix}"));
|
||||||
|
|
||||||
|
Ok(urls)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_base_url(url: &str) -> Result<String> {
|
fn extract_base_url(url: &str) -> Result<String> {
|
||||||
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||||
let scheme = parsed.scheme();
|
let scheme = parsed.scheme();
|
||||||
@@ -296,6 +374,101 @@ mod tests {
|
|||||||
assert!(extract_base_url("not-a-url").is_err());
|
assert!(extract_base_url("not-a-url").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_known_urls_path_aware_first_for_url_with_path() {
|
||||||
|
let urls = well_known_urls(
|
||||||
|
"https://api.githubcopilot.com/mcp",
|
||||||
|
"oauth-protected-resource",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
urls,
|
||||||
|
vec![
|
||||||
|
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp",
|
||||||
|
"https://api.githubcopilot.com/mcp/.well-known/oauth-protected-resource",
|
||||||
|
"https://api.githubcopilot.com/.well-known/oauth-protected-resource",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_known_urls_inserts_before_issuer_path() {
|
||||||
|
let urls = well_known_urls(
|
||||||
|
"https://github.com/login/oauth",
|
||||||
|
"oauth-authorization-server",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
urls[0],
|
||||||
|
"https://github.com/.well-known/oauth-authorization-server/login/oauth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_known_urls_root_only_for_url_without_path() {
|
||||||
|
let urls = well_known_urls("https://mcp.notion.com", "oauth-authorization-server").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
urls,
|
||||||
|
vec!["https://mcp.notion.com/.well-known/oauth-authorization-server"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_known_urls_ignores_trailing_slash() {
|
||||||
|
let urls = well_known_urls(
|
||||||
|
"https://api.githubcopilot.com/mcp/",
|
||||||
|
"oauth-protected-resource",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
urls[0],
|
||||||
|
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resource_metadata_extracts_quoted_url() {
|
||||||
|
let challenge = r#"Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp""#;
|
||||||
|
|
||||||
|
let url = parse_resource_metadata(challenge);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
Some(
|
||||||
|
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resource_metadata_extracts_unquoted_url() {
|
||||||
|
let challenge = "Bearer resource_metadata=https://example.com/.well-known/oauth-protected-resource/mcp, error=\"invalid_token\"";
|
||||||
|
|
||||||
|
let url = parse_resource_metadata(challenge);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
Some("https://example.com/.well-known/oauth-protected-resource/mcp".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resource_metadata_returns_none_when_absent() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_resource_metadata(r#"Bearer error="invalid_token""#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_resource_metadata(r#"Bearer resource_metadata="""#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn registered_client_id_roundtrip() {
|
fn registered_client_id_roundtrip() {
|
||||||
|
|||||||
@@ -358,17 +358,16 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::function::JsonSchema;
|
use crate::function::JsonSchema;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static PARSE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
fn parse_source(
|
fn parse_source(
|
||||||
source: &str,
|
source: &str,
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
parent: &Path,
|
parent: &Path,
|
||||||
) -> Result<Vec<FunctionDeclaration>> {
|
) -> Result<Vec<FunctionDeclaration>> {
|
||||||
let unique = SystemTime::now()
|
let unique = PARSE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("time went backwards")
|
|
||||||
.as_nanos();
|
|
||||||
let path =
|
let path =
|
||||||
std::env::temp_dir().join(format!("coyote_python_parser_{file_name}_{unique}.py"));
|
std::env::temp_dir().join(format!("coyote_python_parser_{file_name}_{unique}.py"));
|
||||||
fs::write(&path, source).expect("failed to write temp python source");
|
fs::write(&path, source).expect("failed to write temp python source");
|
||||||
|
|||||||
+642
-20
@@ -2,12 +2,21 @@ use super::DocumentId;
|
|||||||
use crate::client::*;
|
use crate::client::*;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use indexmap::IndexMap;
|
use indexmap::{IndexMap, IndexSet};
|
||||||
use petgraph::Direction;
|
use petgraph::Direction;
|
||||||
use petgraph::graph::NodeIndex;
|
use petgraph::graph::NodeIndex;
|
||||||
use petgraph::stable_graph::StableGraph;
|
use petgraph::stable_graph::StableGraph;
|
||||||
|
use petgraph::visit::EdgeRef;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
/// Heuristic upper bound on chunk size before warning the user that the
|
||||||
|
/// extraction LLM call may be truncated. Not a hard limit.
|
||||||
|
const MAX_CHUNK_CHARS: usize = 24_000;
|
||||||
|
|
||||||
|
/// Maximum number of nodes the BFS may visit during a single graph_search.
|
||||||
|
/// Keeps the synchronous traversal bounded on dense graphs.
|
||||||
|
pub const MAX_GRAPH_NODES: usize = 500;
|
||||||
|
|
||||||
const EXTRACTION_PROMPT: &str = r#"Extract entities and relationships from the following text chunk.
|
const EXTRACTION_PROMPT: &str = r#"Extract entities and relationships from the following text chunk.
|
||||||
|
|
||||||
@@ -89,16 +98,27 @@ impl Default for KnowledgeGraph {
|
|||||||
|
|
||||||
impl KnowledgeGraph {
|
impl KnowledgeGraph {
|
||||||
pub fn merge(&mut self, doc_id: DocumentId, result: ExtractionResult) {
|
pub fn merge(&mut self, doc_id: DocumentId, result: ExtractionResult) {
|
||||||
let mut chunk_nodes: Vec<u32> = vec![];
|
let mut chunk_nodes: IndexSet<u32> = IndexSet::new();
|
||||||
|
|
||||||
for extracted in &result.entities {
|
for extracted in &result.entities {
|
||||||
let key = extracted.name.to_lowercase();
|
let key = extracted.name.to_lowercase();
|
||||||
|
let normalized_type = extracted.entity_type.to_uppercase();
|
||||||
let node_raw = if let Some(&existing) = self.entity_index.get(&key) {
|
let node_raw = if let Some(&existing) = self.entity_index.get(&key) {
|
||||||
|
let idx = NodeIndex::new(existing as usize);
|
||||||
|
if self.graph.contains_node(idx) {
|
||||||
|
let node = &mut self.graph[idx];
|
||||||
|
if node.entity_type == "OTHER" && normalized_type != "OTHER" {
|
||||||
|
node.entity_type = normalized_type;
|
||||||
|
}
|
||||||
|
if node.description.is_none() {
|
||||||
|
node.description = extracted.description.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
existing
|
existing
|
||||||
} else {
|
} else {
|
||||||
let entity = Entity {
|
let entity = Entity {
|
||||||
name: extracted.name.clone(),
|
name: extracted.name.clone(),
|
||||||
entity_type: extracted.entity_type.clone(),
|
entity_type: normalized_type,
|
||||||
description: extracted.description.clone(),
|
description: extracted.description.clone(),
|
||||||
};
|
};
|
||||||
let idx = self.graph.add_node(entity);
|
let idx = self.graph.add_node(entity);
|
||||||
@@ -106,7 +126,7 @@ impl KnowledgeGraph {
|
|||||||
self.entity_index.insert(key, raw);
|
self.entity_index.insert(key, raw);
|
||||||
raw
|
raw
|
||||||
};
|
};
|
||||||
chunk_nodes.push(node_raw);
|
chunk_nodes.insert(node_raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
for extracted in &result.relationships {
|
for extracted in &result.relationships {
|
||||||
@@ -118,11 +138,14 @@ impl KnowledgeGraph {
|
|||||||
) {
|
) {
|
||||||
let from_idx = NodeIndex::new(from_raw as usize);
|
let from_idx = NodeIndex::new(from_raw as usize);
|
||||||
let to_idx = NodeIndex::new(to_raw as usize);
|
let to_idx = NodeIndex::new(to_raw as usize);
|
||||||
// Avoid duplicate edges
|
let already_exists = self
|
||||||
if !self.graph.contains_edge(from_idx, to_idx) {
|
.graph
|
||||||
|
.edges_connecting(from_idx, to_idx)
|
||||||
|
.any(|e| e.weight().relation_type == extracted.relation_type);
|
||||||
|
if !already_exists {
|
||||||
let rel = Relationship {
|
let rel = Relationship {
|
||||||
relation_type: extracted.relation_type.clone(),
|
relation_type: extracted.relation_type.clone(),
|
||||||
weight: extracted.weight.unwrap_or(1.0),
|
weight: extracted.weight.unwrap_or(1.0).clamp(0.0, 1.0),
|
||||||
};
|
};
|
||||||
self.graph.add_edge(from_idx, to_idx, rel);
|
self.graph.add_edge(from_idx, to_idx, rel);
|
||||||
}
|
}
|
||||||
@@ -158,6 +181,10 @@ impl KnowledgeGraph {
|
|||||||
.filter(|raw| !still_used.contains(raw))
|
.filter(|raw| !still_used.contains(raw))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
if to_remove.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for raw in to_remove {
|
for raw in to_remove {
|
||||||
let idx = NodeIndex::new(raw as usize);
|
let idx = NodeIndex::new(raw as usize);
|
||||||
if self.graph.contains_node(idx) {
|
if self.graph.contains_node(idx) {
|
||||||
@@ -166,6 +193,57 @@ impl KnowledgeGraph {
|
|||||||
self.entity_index.swap_remove(&name);
|
self.entity_index.swap_remove(&name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the internal graph with consecutive node indices. Eliminates
|
||||||
|
/// the null tombstone slots that petgraph's StableGraph accumulates after
|
||||||
|
/// repeated `remove_node` calls, keeping serialized YAML size in check.
|
||||||
|
fn compact(&mut self) {
|
||||||
|
let mut new_graph: StableGraph<Entity, Relationship> = StableGraph::new();
|
||||||
|
let mut old_to_new: HashMap<u32, u32> = HashMap::new();
|
||||||
|
|
||||||
|
for &old_raw in self.entity_index.values() {
|
||||||
|
let old_idx = NodeIndex::new(old_raw as usize);
|
||||||
|
if self.graph.contains_node(old_idx) {
|
||||||
|
let entity = self.graph[old_idx].clone();
|
||||||
|
let new_idx = new_graph.add_node(entity);
|
||||||
|
old_to_new.insert(old_raw, new_idx.index() as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for edge_idx in self.graph.edge_indices() {
|
||||||
|
if let Some((from, to)) = self.graph.edge_endpoints(edge_idx) {
|
||||||
|
let from_raw = from.index() as u32;
|
||||||
|
let to_raw = to.index() as u32;
|
||||||
|
if let (Some(&new_from), Some(&new_to)) =
|
||||||
|
(old_to_new.get(&from_raw), old_to_new.get(&to_raw))
|
||||||
|
{
|
||||||
|
let rel = self.graph[edge_idx].clone();
|
||||||
|
new_graph.add_edge(
|
||||||
|
NodeIndex::new(new_from as usize),
|
||||||
|
NodeIndex::new(new_to as usize),
|
||||||
|
rel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for raw in self.entity_index.values_mut() {
|
||||||
|
if let Some(&new_raw) = old_to_new.get(raw) {
|
||||||
|
*raw = new_raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for node_raws in self.document_entities.values_mut() {
|
||||||
|
*node_raws = node_raws
|
||||||
|
.iter()
|
||||||
|
.filter_map(|raw| old_to_new.get(raw).copied())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.graph = new_graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_node_to_docs(&self) -> IndexMap<u32, Vec<DocumentId>> {
|
pub fn build_node_to_docs(&self) -> IndexMap<u32, Vec<DocumentId>> {
|
||||||
@@ -179,30 +257,79 @@ impl KnowledgeGraph {
|
|||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expand_neighbors(&self, seed_nodes: &[u32], hops: usize) -> Vec<u32> {
|
/// BFS from seed nodes with weight-decayed scoring.
|
||||||
let mut expanded: indexmap::IndexSet<u32> = seed_nodes.iter().copied().collect();
|
///
|
||||||
let mut frontier: Vec<u32> = seed_nodes.to_vec();
|
/// Seed node scores are provided by the caller (typically token-overlap
|
||||||
|
/// ratios). Each neighbor's score is `edge_weight * parent_score`, so
|
||||||
|
/// strongly-connected neighbors rank higher and weakly-connected ones
|
||||||
|
/// naturally contribute less. Traversal is capped at `MAX_GRAPH_NODES`
|
||||||
|
/// total nodes; the highest-scored frontier nodes are expanded first so
|
||||||
|
/// the budget is spent on the most relevant entities.
|
||||||
|
///
|
||||||
|
/// Returns a map of raw node index → score (includes seed nodes).
|
||||||
|
pub fn expand_neighbors_scored(
|
||||||
|
&self,
|
||||||
|
seed_scores: &[(u32, f32)],
|
||||||
|
hops: usize,
|
||||||
|
) -> IndexMap<u32, f32> {
|
||||||
|
let mut node_scores: IndexMap<u32, f32> = IndexMap::new();
|
||||||
|
for &(raw, score) in seed_scores {
|
||||||
|
node_scores.insert(raw, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut frontier: Vec<(u32, f32)> = seed_scores.to_vec();
|
||||||
|
|
||||||
for _ in 0..hops {
|
for _ in 0..hops {
|
||||||
let mut next_frontier: Vec<u32> = vec![];
|
if node_scores.len() >= MAX_GRAPH_NODES {
|
||||||
for &raw in &frontier {
|
break;
|
||||||
let idx = NodeIndex::new(raw as usize);
|
}
|
||||||
if self.graph.contains_node(idx) {
|
|
||||||
|
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 dir in [Direction::Outgoing, Direction::Incoming] {
|
||||||
for neighbor in self.graph.neighbors_directed(idx, dir) {
|
for edge_ref in self.graph.edges_directed(idx, dir) {
|
||||||
let n = neighbor.index() as u32;
|
let neighbor_idx = match dir {
|
||||||
if expanded.insert(n) {
|
Direction::Outgoing => edge_ref.target(),
|
||||||
next_frontier.push(n);
|
Direction::Incoming => edge_ref.source(),
|
||||||
|
};
|
||||||
|
let neighbor_raw = neighbor_idx.index() as u32;
|
||||||
|
let candidate = edge_ref.weight().weight * parent_score;
|
||||||
|
|
||||||
|
match node_scores.entry(neighbor_raw) {
|
||||||
|
indexmap::map::Entry::Vacant(e) => {
|
||||||
|
e.insert(candidate);
|
||||||
|
next_frontier.push((neighbor_raw, candidate));
|
||||||
}
|
}
|
||||||
|
indexmap::map::Entry::Occupied(mut e) => {
|
||||||
|
if candidate > *e.get() {
|
||||||
|
*e.get_mut() = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if node_scores.len() >= MAX_GRAPH_NODES {
|
||||||
|
break 'nodes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
frontier = next_frontier;
|
frontier = next_frontier;
|
||||||
if frontier.is_empty() {
|
if frontier.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expanded.into_iter().collect()
|
|
||||||
|
node_scores
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +340,14 @@ pub async fn extract_entities(
|
|||||||
chunk: &str,
|
chunk: &str,
|
||||||
prompt_template: Option<&str>,
|
prompt_template: Option<&str>,
|
||||||
) -> Result<ExtractionResult> {
|
) -> Result<ExtractionResult> {
|
||||||
|
if chunk.len() > MAX_CHUNK_CHARS {
|
||||||
|
warn!(
|
||||||
|
"Entity extraction chunk is {} chars (heuristic limit: {}); \
|
||||||
|
the LLM response may be truncated",
|
||||||
|
chunk.len(),
|
||||||
|
MAX_CHUNK_CHARS
|
||||||
|
);
|
||||||
|
}
|
||||||
let template = prompt_template.unwrap_or(EXTRACTION_PROMPT);
|
let template = prompt_template.unwrap_or(EXTRACTION_PROMPT);
|
||||||
let prompt = template.replace("__CHUNK__", chunk);
|
let prompt = template.replace("__CHUNK__", chunk);
|
||||||
let mut messages = vec![Message::new(
|
let mut messages = vec![Message::new(
|
||||||
@@ -227,6 +362,7 @@ pub async fn extract_entities(
|
|||||||
messages,
|
messages,
|
||||||
temperature: Some(0.0),
|
temperature: Some(0.0),
|
||||||
top_p: None,
|
top_p: None,
|
||||||
|
reasoning_effort: None,
|
||||||
functions: None,
|
functions: None,
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
};
|
||||||
@@ -250,3 +386,489 @@ pub async fn extract_entities(
|
|||||||
serde_json::from_str::<ExtractionResult>(&json)
|
serde_json::from_str::<ExtractionResult>(&json)
|
||||||
.context("Failed to parse entity extraction JSON")
|
.context("Failed to parse entity extraction JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entity(name: &str, entity_type: &str) -> ExtractedEntity {
|
||||||
|
ExtractedEntity {
|
||||||
|
name: name.to_string(),
|
||||||
|
entity_type: entity_type.to_string(),
|
||||||
|
description: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rel(from: &str, to: &str, rel_type: &str, weight: f32) -> ExtractedRelationship {
|
||||||
|
ExtractedRelationship {
|
||||||
|
from: from.to_string(),
|
||||||
|
to: to.to_string(),
|
||||||
|
relation_type: rel_type.to_string(),
|
||||||
|
weight: Some(weight),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(id: usize) -> DocumentId {
|
||||||
|
DocumentId(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extraction(
|
||||||
|
entities: Vec<ExtractedEntity>,
|
||||||
|
rels: Vec<ExtractedRelationship>,
|
||||||
|
) -> ExtractionResult {
|
||||||
|
ExtractionResult {
|
||||||
|
entities,
|
||||||
|
relationships: rels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_deduplicates_by_lowercase_name() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![
|
||||||
|
entity("Python", "TECHNOLOGY"),
|
||||||
|
entity("python", "TECHNOLOGY"),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(kg.entity_index.len(), 1);
|
||||||
|
assert_eq!(kg.graph.node_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_chunk_nodes_no_duplicate_doc_entries() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(1),
|
||||||
|
extraction(
|
||||||
|
vec![
|
||||||
|
entity("Python", "TECHNOLOGY"),
|
||||||
|
entity("python", "TECHNOLOGY"),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let count = kg.document_entities.get(&1).map(|v| v.len()).unwrap_or(0);
|
||||||
|
assert_eq!(
|
||||||
|
count, 1,
|
||||||
|
"duplicate entity in one chunk should produce one doc_entity entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_normalizes_entity_type_to_uppercase() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(vec![entity("Django", "technology")], vec![]),
|
||||||
|
);
|
||||||
|
let raw = kg.entity_index["django"];
|
||||||
|
assert_eq!(
|
||||||
|
kg.graph[NodeIndex::new(raw as usize)].entity_type,
|
||||||
|
"TECHNOLOGY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_promotes_type_from_other_to_specific() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(doc(0), extraction(vec![entity("Python", "OTHER")], vec![]));
|
||||||
|
kg.merge(
|
||||||
|
doc(1),
|
||||||
|
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
|
||||||
|
);
|
||||||
|
let raw = kg.entity_index["python"];
|
||||||
|
assert_eq!(
|
||||||
|
kg.graph[NodeIndex::new(raw as usize)].entity_type,
|
||||||
|
"TECHNOLOGY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_does_not_demote_specific_type_to_other() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
|
||||||
|
);
|
||||||
|
kg.merge(doc(1), extraction(vec![entity("Python", "OTHER")], vec![]));
|
||||||
|
let raw = kg.entity_index["python"];
|
||||||
|
assert_eq!(
|
||||||
|
kg.graph[NodeIndex::new(raw as usize)].entity_type,
|
||||||
|
"TECHNOLOGY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_allows_multiple_relation_types_between_same_pair() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![
|
||||||
|
entity("Python", "TECHNOLOGY"),
|
||||||
|
entity("Django", "TECHNOLOGY"),
|
||||||
|
],
|
||||||
|
vec![rel("Python", "Django", "implements", 0.9)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
kg.merge(
|
||||||
|
doc(1),
|
||||||
|
extraction(
|
||||||
|
vec![
|
||||||
|
entity("Python", "TECHNOLOGY"),
|
||||||
|
entity("Django", "TECHNOLOGY"),
|
||||||
|
],
|
||||||
|
vec![rel("Python", "Django", "uses", 0.8)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let from_idx = NodeIndex::new(kg.entity_index["python"] as usize);
|
||||||
|
let to_idx = NodeIndex::new(kg.entity_index["django"] as usize);
|
||||||
|
let count = kg.graph.edges_connecting(from_idx, to_idx).count();
|
||||||
|
assert_eq!(
|
||||||
|
count, 2,
|
||||||
|
"two different relation types should produce two edges"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_deduplicates_same_relation_type() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
|
||||||
|
vec![rel("A", "B", "uses", 1.0)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
kg.merge(
|
||||||
|
doc(1),
|
||||||
|
extraction(
|
||||||
|
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
|
||||||
|
vec![rel("A", "B", "uses", 0.5)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let from_idx = NodeIndex::new(kg.entity_index["a"] as usize);
|
||||||
|
let to_idx = NodeIndex::new(kg.entity_index["b"] as usize);
|
||||||
|
let count = kg.graph.edges_connecting(from_idx, to_idx).count();
|
||||||
|
assert_eq!(
|
||||||
|
count, 1,
|
||||||
|
"same relation type should not create a duplicate edge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_documents_preserves_entity_shared_across_docs() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![entity("Python", "TECHNOLOGY"), entity("A", "CONCEPT")],
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
kg.merge(
|
||||||
|
doc(1),
|
||||||
|
extraction(
|
||||||
|
vec![entity("Python", "TECHNOLOGY"), entity("B", "CONCEPT")],
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
kg.remove_documents(&[doc(0)]);
|
||||||
|
assert!(
|
||||||
|
kg.entity_index.contains_key("python"),
|
||||||
|
"shared entity should survive"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!kg.entity_index.contains_key("a"),
|
||||||
|
"exclusive entity should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
kg.entity_index.contains_key("b"),
|
||||||
|
"other doc's entity should survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_documents_noop_on_empty_slice() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(doc(0), extraction(vec![entity("X", "CONCEPT")], vec![]));
|
||||||
|
kg.remove_documents(&[]);
|
||||||
|
assert_eq!(kg.entity_index.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_documents_compacts_graph() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
// doc 0: A, B with an edge
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
|
||||||
|
vec![rel("A", "B", "uses", 1.0)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// doc 1: C only
|
||||||
|
kg.merge(doc(1), extraction(vec![entity("C", "CONCEPT")], vec![]));
|
||||||
|
|
||||||
|
kg.remove_documents(&[doc(0)]);
|
||||||
|
|
||||||
|
assert_eq!(kg.graph.node_count(), 1);
|
||||||
|
let c_raw = kg.entity_index["c"];
|
||||||
|
assert_eq!(
|
||||||
|
c_raw, 0,
|
||||||
|
"compacted graph should give surviving node index 0"
|
||||||
|
);
|
||||||
|
let refs = kg.document_entities.get(&1).cloned().unwrap_or_default();
|
||||||
|
assert_eq!(refs, vec![0u32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_zero_hops_returns_seeds_only() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
|
||||||
|
vec![rel("A", "B", "uses", 0.9)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let a_raw = kg.entity_index["a"];
|
||||||
|
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 0);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[&a_raw], 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_one_hop_decays_score_by_edge_weight() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
|
||||||
|
vec![rel("A", "B", "uses", 0.8)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let a_raw = kg.entity_index["a"];
|
||||||
|
let b_raw = kg.entity_index["b"];
|
||||||
|
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[&a_raw], 1.0);
|
||||||
|
let b_score = result[&b_raw];
|
||||||
|
assert!(
|
||||||
|
(b_score - 0.8).abs() < 1e-6,
|
||||||
|
"neighbor score should be edge_weight * parent_score = 0.8, got {b_score}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_incoming_edges_also_traversed() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
// Edge goes B → A; seeding A should still discover B via incoming edge
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
|
||||||
|
vec![rel("B", "A", "uses", 0.7)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let a_raw = kg.entity_index["a"];
|
||||||
|
let b_raw = kg.entity_index["b"];
|
||||||
|
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
|
||||||
|
assert!(
|
||||||
|
result.contains_key(&b_raw),
|
||||||
|
"B should be reachable via incoming edge from A"
|
||||||
|
);
|
||||||
|
let b_score = result[&b_raw];
|
||||||
|
assert!((b_score - 0.7).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_picks_best_path_score() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
// A(0.5) → C(0.9): score 0.45; B(1.0) → C(0.4): score 0.40 — A→C path wins.
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![
|
||||||
|
entity("A", "CONCEPT"),
|
||||||
|
entity("B", "CONCEPT"),
|
||||||
|
entity("C", "CONCEPT"),
|
||||||
|
],
|
||||||
|
vec![rel("A", "C", "uses", 0.9), rel("B", "C", "uses", 0.4)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let a_raw = kg.entity_index["a"];
|
||||||
|
let b_raw = kg.entity_index["b"];
|
||||||
|
let c_raw = kg.entity_index["c"];
|
||||||
|
let seeds = vec![(a_raw, 0.5f32), (b_raw, 1.0f32)];
|
||||||
|
let result = kg.expand_neighbors_scored(&seeds, 1);
|
||||||
|
let c_score = result[&c_raw];
|
||||||
|
// Best path: B(1.0) * 0.4 = 0.4, A(0.5) * 0.9 = 0.45 → should be 0.45
|
||||||
|
assert!(
|
||||||
|
(c_score - 0.45).abs() < 1e-6,
|
||||||
|
"C score should reflect best path (0.45), got {c_score}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_node_to_docs_maps_shared_entity_to_multiple_docs() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
|
||||||
|
);
|
||||||
|
kg.merge(
|
||||||
|
doc(1),
|
||||||
|
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
|
||||||
|
);
|
||||||
|
let n2d = kg.build_node_to_docs();
|
||||||
|
let raw = kg.entity_index["python"];
|
||||||
|
let docs = &n2d[&raw];
|
||||||
|
assert!(docs.contains(&DocumentId(0)));
|
||||||
|
assert!(docs.contains(&DocumentId(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_preserves_edges_between_survivors() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(doc(0), extraction(vec![entity("A", "CONCEPT")], vec![]));
|
||||||
|
kg.merge(
|
||||||
|
doc(1),
|
||||||
|
extraction(
|
||||||
|
vec![entity("B", "CONCEPT"), entity("C", "CONCEPT")],
|
||||||
|
vec![rel("B", "C", "linked", 0.8)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
kg.remove_documents(&[doc(0)]);
|
||||||
|
let b_raw = kg.entity_index["b"];
|
||||||
|
let c_raw = kg.entity_index["c"];
|
||||||
|
let b_idx = NodeIndex::new(b_raw as usize);
|
||||||
|
let c_idx = NodeIndex::new(c_raw as usize);
|
||||||
|
assert_eq!(
|
||||||
|
kg.graph.edges_connecting(b_idx, c_idx).count(),
|
||||||
|
1,
|
||||||
|
"B→C edge should survive compaction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_two_hops_reaches_transitive_neighbor() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![
|
||||||
|
entity("A", "CONCEPT"),
|
||||||
|
entity("B", "CONCEPT"),
|
||||||
|
entity("C", "CONCEPT"),
|
||||||
|
],
|
||||||
|
vec![rel("A", "B", "uses", 1.0), rel("B", "C", "uses", 0.5)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let a_raw = kg.entity_index["a"];
|
||||||
|
let c_raw = kg.entity_index["c"];
|
||||||
|
|
||||||
|
let one_hop = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
|
||||||
|
assert!(
|
||||||
|
!one_hop.contains_key(&c_raw),
|
||||||
|
"C should not be reachable at 1 hop"
|
||||||
|
);
|
||||||
|
|
||||||
|
let two_hop = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 2);
|
||||||
|
assert!(
|
||||||
|
two_hop.contains_key(&c_raw),
|
||||||
|
"C should be reachable at 2 hops"
|
||||||
|
);
|
||||||
|
let c_score = two_hop[&c_raw];
|
||||||
|
assert!(
|
||||||
|
(c_score - 0.5).abs() < 1e-6,
|
||||||
|
"C score should be 1.0 * 1.0 * 0.5 = 0.5, got {c_score}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_clamps_edge_weight_above_one() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
|
||||||
|
vec![rel("A", "B", "uses", 1.5)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let a_raw = kg.entity_index["a"];
|
||||||
|
let b_raw = kg.entity_index["b"];
|
||||||
|
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
|
||||||
|
let b_score = result[&b_raw];
|
||||||
|
assert!(
|
||||||
|
(b_score - 1.0).abs() < 1e-6,
|
||||||
|
"weight 1.5 clamped to 1.0: b_score should be 1.0, got {b_score}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_clamps_edge_weight_below_zero() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(
|
||||||
|
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
|
||||||
|
vec![rel("A", "B", "uses", -0.5)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let a_raw = kg.entity_index["a"];
|
||||||
|
let b_raw = kg.entity_index["b"];
|
||||||
|
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
|
||||||
|
let b_score = result.get(&b_raw).copied().unwrap_or(0.0);
|
||||||
|
assert!(
|
||||||
|
b_score.abs() < 1e-6,
|
||||||
|
"weight -0.5 clamped to 0.0: b_score should be 0.0, got {b_score}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_fills_missing_description_from_later_chunk() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(
|
||||||
|
doc(0),
|
||||||
|
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
|
||||||
|
);
|
||||||
|
kg.merge(
|
||||||
|
doc(1),
|
||||||
|
ExtractionResult {
|
||||||
|
entities: vec![ExtractedEntity {
|
||||||
|
name: "python".to_string(),
|
||||||
|
entity_type: "TECHNOLOGY".to_string(),
|
||||||
|
description: Some("A general-purpose language".to_string()),
|
||||||
|
}],
|
||||||
|
relationships: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let raw = kg.entity_index["python"];
|
||||||
|
let desc = &kg.graph[NodeIndex::new(raw as usize)].description;
|
||||||
|
assert_eq!(
|
||||||
|
desc.as_deref(),
|
||||||
|
Some("A general-purpose language"),
|
||||||
|
"description should be backfilled from later chunk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_all_documents_empties_graph() {
|
||||||
|
let mut kg = KnowledgeGraph::default();
|
||||||
|
kg.merge(doc(0), extraction(vec![entity("A", "CONCEPT")], vec![]));
|
||||||
|
kg.merge(doc(1), extraction(vec![entity("B", "CONCEPT")], vec![]));
|
||||||
|
kg.remove_documents(&[doc(0), doc(1)]);
|
||||||
|
assert_eq!(kg.graph.node_count(), 0, "all nodes should be removed");
|
||||||
|
assert_eq!(kg.entity_index.len(), 0, "entity index should be empty");
|
||||||
|
assert!(
|
||||||
|
kg.document_entities.is_empty(),
|
||||||
|
"document_entities should be empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+137
-40
@@ -25,6 +25,8 @@ use std::{
|
|||||||
};
|
};
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
const BM25_SEED_SCORE: f32 = 0.5;
|
||||||
|
|
||||||
const RAG_TEMPLATE: &str = r#"Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
|
const RAG_TEMPLATE: &str = r#"Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
|
||||||
|
|
||||||
<context>
|
<context>
|
||||||
@@ -752,14 +754,14 @@ impl Rag {
|
|||||||
bail!("No RAG files");
|
bail!("No RAG files");
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.data.extractor_model.is_some()
|
if !new_doc_contents.is_empty()
|
||||||
&& !new_doc_contents.is_empty()
|
|
||||||
&& let Some(extractor_model_id) = self.data.extractor_model.clone()
|
&& let Some(extractor_model_id) = self.data.extractor_model.clone()
|
||||||
{
|
{
|
||||||
match Model::retrieve_model(&self.app_config, &extractor_model_id, ModelType::Chat) {
|
match Model::retrieve_model(&self.app_config, &extractor_model_id, ModelType::Chat) {
|
||||||
Ok(model) => match self.create_embeddings_client(model) {
|
Ok(model) => match self.create_embeddings_client(model) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
let total = new_doc_contents.len();
|
let total = new_doc_contents.len();
|
||||||
|
let mut failures = 0usize;
|
||||||
for (i, (doc_id, content)) in new_doc_contents.into_iter().enumerate() {
|
for (i, (doc_id, content)) in new_doc_contents.into_iter().enumerate() {
|
||||||
progress(
|
progress(
|
||||||
&spinner,
|
&spinner,
|
||||||
@@ -774,14 +776,21 @@ impl Rag {
|
|||||||
{
|
{
|
||||||
Ok(result) => self.data.knowledge_graph.merge(doc_id, result),
|
Ok(result) => self.data.knowledge_graph.merge(doc_id, result),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
debug!("Entity extraction failed for doc {doc_id:?}: {e}")
|
warn!("Entity extraction failed for doc {doc_id:?}: {e}");
|
||||||
|
failures += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if failures > 0 {
|
||||||
|
progress(
|
||||||
|
&spinner,
|
||||||
|
format!("Entity extraction: {failures}/{total} chunks failed"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => debug!("Failed to create extractor client: {e}"),
|
}
|
||||||
|
Err(e) => warn!("Failed to create extractor client: {e}"),
|
||||||
},
|
},
|
||||||
Err(e) => debug!("Extractor model not found: {e}"),
|
Err(e) => warn!("Extractor model not found: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,9 +939,31 @@ impl Rag {
|
|||||||
if kg.entity_index.is_empty() {
|
if kg.entity_index.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
let query_lower = query.to_lowercase();
|
|
||||||
|
|
||||||
let mut seed_nodes: Vec<u32> = kg
|
let query_lower = query.to_lowercase();
|
||||||
|
let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
|
||||||
|
let token_count = query_tokens.len().max(1);
|
||||||
|
|
||||||
|
let score_node = |raw: u32| -> f32 {
|
||||||
|
let idx = NodeIndex::new(raw as usize);
|
||||||
|
if !kg.graph.contains_node(idx) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let entity = &kg.graph[idx];
|
||||||
|
let combined = format!(
|
||||||
|
"{} {}",
|
||||||
|
entity.name,
|
||||||
|
entity.description.as_deref().unwrap_or("")
|
||||||
|
)
|
||||||
|
.to_lowercase();
|
||||||
|
query_tokens
|
||||||
|
.iter()
|
||||||
|
.filter(|t| combined.contains(*t))
|
||||||
|
.count() as f32
|
||||||
|
/ token_count as f32
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut seed_scores: Vec<(u32, f32)> = kg
|
||||||
.entity_index
|
.entity_index
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(name, _)| {
|
.filter(|(name, _)| {
|
||||||
@@ -946,52 +977,31 @@ impl Rag {
|
|||||||
.any(|token| token.trim_matches(|c: char| !c.is_alphanumeric()) == name_str)
|
.any(|token| token.trim_matches(|c: char| !c.is_alphanumeric()) == name_str)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(|(_, &raw)| raw)
|
.map(|(_, &raw)| (raw, score_node(raw).max(BM25_SEED_SCORE)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if seed_nodes.is_empty() {
|
if seed_scores.is_empty() {
|
||||||
let bm25_results = self.bm25.search(query, top_k * 2);
|
let bm25_results = self.bm25.search(query, top_k * 2);
|
||||||
'outer: for result in bm25_results {
|
'outer: for result in bm25_results {
|
||||||
if let Some(node_raws) = kg.document_entities.get(&result.document.id.0) {
|
if let Some(node_raws) = kg.document_entities.get(&result.document.id.0) {
|
||||||
seed_nodes.extend(node_raws.iter().copied());
|
for &raw in node_raws {
|
||||||
if seed_nodes.len() >= top_k {
|
seed_scores.push((raw, BM25_SEED_SCORE));
|
||||||
|
if seed_scores.len() >= top_k {
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if seed_nodes.is_empty() {
|
if seed_scores.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
let hops = self.data.graph_hops.unwrap_or(1);
|
let hops = self.data.graph_hops.unwrap_or(1);
|
||||||
let expanded = kg.expand_neighbors(&seed_nodes, hops);
|
let mut scored: Vec<(u32, f32)> = kg
|
||||||
|
.expand_neighbors_scored(&seed_scores, hops)
|
||||||
let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
|
|
||||||
let token_count = query_tokens.len().max(1);
|
|
||||||
let mut scored: Vec<(u32, f32)> = expanded
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|raw| {
|
|
||||||
let idx = NodeIndex::new(raw as usize);
|
|
||||||
let score = if kg.graph.contains_node(idx) {
|
|
||||||
let entity = &kg.graph[idx];
|
|
||||||
let combined = format!(
|
|
||||||
"{} {}",
|
|
||||||
entity.name,
|
|
||||||
entity.description.as_deref().unwrap_or("")
|
|
||||||
)
|
|
||||||
.to_lowercase();
|
|
||||||
query_tokens
|
|
||||||
.iter()
|
|
||||||
.filter(|t| combined.contains(*t))
|
|
||||||
.count() as f32
|
|
||||||
/ token_count as f32
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
(raw, score)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
|
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
|
||||||
|
|
||||||
@@ -1349,11 +1359,11 @@ fn set_chunk_size(model: &Model) -> Result<usize> {
|
|||||||
fn set_graph_hops(default_value: usize) -> Result<usize> {
|
fn set_graph_hops(default_value: usize) -> Result<usize> {
|
||||||
let value = Text::new("Set graph expansion hops:")
|
let value = Text::new("Set graph expansion hops:")
|
||||||
.with_default(&default_value.to_string())
|
.with_default(&default_value.to_string())
|
||||||
.with_help_message("Number of hops to expand from matched entities (1 = direct neighbors, 2 = neighbors of neighbors)")
|
.with_help_message("Number of hops to expand from matched entities (0 = seed nodes only, 1 = direct neighbors, 2 = neighbors of neighbors)")
|
||||||
.with_validator(move |text: &str| {
|
.with_validator(move |text: &str| {
|
||||||
let out = match text.parse::<usize>() {
|
let out = match text.parse::<usize>() {
|
||||||
Ok(v) if v >= 1 => Validation::Valid,
|
Ok(_) => Validation::Valid,
|
||||||
_ => Validation::Invalid("Must be an integer >= 1".into()),
|
_ => Validation::Invalid("Must be a non-negative integer".into()),
|
||||||
};
|
};
|
||||||
Ok(out)
|
Ok(out)
|
||||||
})
|
})
|
||||||
@@ -1771,4 +1781,91 @@ mod tests {
|
|||||||
assert_eq!(file_idx, 0);
|
assert_eq!(file_idx, 0);
|
||||||
assert_eq!(doc_idx, 0);
|
assert_eq!(doc_idx, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rag_data_del_removes_graph_entities() {
|
||||||
|
use super::graph::{ExtractedEntity, ExtractionResult};
|
||||||
|
let mut data = RagData::new(
|
||||||
|
"m".into(),
|
||||||
|
100,
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
5,
|
||||||
|
None,
|
||||||
|
GraphRagConfig::default(),
|
||||||
|
);
|
||||||
|
let file = RagFile {
|
||||||
|
hash: "abc".into(),
|
||||||
|
path: "test.txt".into(),
|
||||||
|
documents: vec![RagDocument::new("Python is great")],
|
||||||
|
};
|
||||||
|
data.files.insert(0, file);
|
||||||
|
let doc_id = DocumentId::new(0, 0);
|
||||||
|
data.knowledge_graph.merge(
|
||||||
|
doc_id,
|
||||||
|
ExtractionResult {
|
||||||
|
entities: vec![ExtractedEntity {
|
||||||
|
name: "Python".to_string(),
|
||||||
|
entity_type: "TECHNOLOGY".to_string(),
|
||||||
|
description: None,
|
||||||
|
}],
|
||||||
|
relationships: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
data.knowledge_graph.entity_index.contains_key("python"),
|
||||||
|
"entity should exist before del"
|
||||||
|
);
|
||||||
|
data.del(vec![0]);
|
||||||
|
assert!(
|
||||||
|
!data.knowledge_graph.entity_index.contains_key("python"),
|
||||||
|
"entity should be removed after del"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reciprocal_rank_fusion_empty_lists() {
|
||||||
|
let result = super::reciprocal_rank_fusion(vec![], vec![], 5);
|
||||||
|
assert!(result.is_empty(), "empty input should produce empty output");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reciprocal_rank_fusion_deduplicates_across_signals() {
|
||||||
|
let doc_a = DocumentId::new(0, 0);
|
||||||
|
let doc_b = DocumentId::new(0, 1);
|
||||||
|
let result = super::reciprocal_rank_fusion(
|
||||||
|
vec![vec![doc_a, doc_b], vec![doc_a, doc_b]],
|
||||||
|
vec![1.0, 1.0],
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
let unique: std::collections::HashSet<_> = result.iter().collect();
|
||||||
|
assert_eq!(
|
||||||
|
unique.len(),
|
||||||
|
result.len(),
|
||||||
|
"each document should appear at most once"
|
||||||
|
);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reciprocal_rank_fusion_respects_top_k() {
|
||||||
|
let docs: Vec<DocumentId> = (0..10).map(|i| DocumentId::new(0, i)).collect();
|
||||||
|
let result = super::reciprocal_rank_fusion(vec![docs], vec![1.0], 3);
|
||||||
|
assert_eq!(result.len(), 3, "result should be capped at top_k=3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reciprocal_rank_fusion_weights_affect_ranking() {
|
||||||
|
let doc_a = DocumentId::new(0, 0);
|
||||||
|
let doc_b = DocumentId::new(0, 1);
|
||||||
|
let result = super::reciprocal_rank_fusion(
|
||||||
|
vec![vec![doc_a, doc_b], vec![doc_b, doc_a]],
|
||||||
|
vec![10.0, 1.0],
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result[0], doc_a,
|
||||||
|
"higher-weight signal's top doc should rank first"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2072
-12
File diff suppressed because it is too large
Load Diff
+20
-5
@@ -2,7 +2,7 @@ use super::{MarkdownRender, SseEvent};
|
|||||||
|
|
||||||
use crate::utils::{AbortSignal, poll_abort_signal, spawn_spinner};
|
use crate::utils::{AbortSignal, poll_abort_signal, spawn_spinner};
|
||||||
|
|
||||||
use anyhow::{Error, Result};
|
use anyhow::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
cursor, queue, style,
|
cursor, queue, style,
|
||||||
terminal::{self, disable_raw_mode, enable_raw_mode},
|
terminal::{self, disable_raw_mode, enable_raw_mode},
|
||||||
@@ -74,6 +74,8 @@ async fn markdown_stream_inner(
|
|||||||
let mut buffer_rows = 1;
|
let mut buffer_rows = 1;
|
||||||
|
|
||||||
let columns = terminal::size()?.0;
|
let columns = terminal::size()?.0;
|
||||||
|
let mut last_col: u16 = 0;
|
||||||
|
let mut last_row: u16 = 0;
|
||||||
|
|
||||||
let mut spinner = Some(spawn_spinner("Generating"));
|
let mut spinner = Some(spawn_spinner("Generating"));
|
||||||
|
|
||||||
@@ -94,9 +96,16 @@ async fn markdown_stream_inner(
|
|||||||
let mut attempts = 0;
|
let mut attempts = 0;
|
||||||
let (col, mut row) = loop {
|
let (col, mut row) = loop {
|
||||||
match cursor::position() {
|
match cursor::position() {
|
||||||
Ok(pos) => break pos,
|
Ok(pos) => {
|
||||||
Err(_) if attempts < 3 => attempts += 1,
|
last_col = pos.0;
|
||||||
Err(e) => return Err(Error::from(e)),
|
last_row = pos.1;
|
||||||
|
break pos;
|
||||||
|
}
|
||||||
|
Err(_) if attempts < 5 => {
|
||||||
|
attempts += 1;
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
Err(_) => break (last_col, last_row),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,7 +132,9 @@ async fn markdown_stream_inner(
|
|||||||
let text = format!("{buffer}{text}");
|
let text = format!("{buffer}{text}");
|
||||||
let (head, tail) = split_line_tail(&text);
|
let (head, tail) = split_line_tail(&text);
|
||||||
let output = render.render(head);
|
let output = render.render(head);
|
||||||
|
if !output.is_empty() {
|
||||||
print_block(writer, &output, columns)?;
|
print_block(writer, &output, columns)?;
|
||||||
|
}
|
||||||
buffer = tail.to_string();
|
buffer = tail.to_string();
|
||||||
} else {
|
} else {
|
||||||
buffer = format!("{buffer}{text}");
|
buffer = format!("{buffer}{text}");
|
||||||
@@ -142,10 +153,14 @@ async fn markdown_stream_inner(
|
|||||||
queue!(writer, style::Print(&output))?;
|
queue!(writer, style::Print(&output))?;
|
||||||
buffer_rows = need_rows(&output, columns);
|
buffer_rows = need_rows(&output, columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.flush()?;
|
writer.flush()?;
|
||||||
}
|
}
|
||||||
SseEvent::Done => {
|
SseEvent::Done => {
|
||||||
|
let tail = render.finalize();
|
||||||
|
if !tail.is_empty() {
|
||||||
|
queue!(writer, style::Print("\n"), style::Print(&tail))?;
|
||||||
|
writer.flush()?;
|
||||||
|
}
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ impl Completer for ReplCompleter {
|
|||||||
|
|
||||||
let ctx = self.ctx.read();
|
let ctx = self.ctx.read();
|
||||||
let state = ctx.state();
|
let state = ctx.state();
|
||||||
|
let model_has_reasoning = !ctx.current_model().reasoning_levels().is_empty();
|
||||||
|
|
||||||
let command_filter = parts
|
let command_filter = parts
|
||||||
.iter()
|
.iter()
|
||||||
@@ -44,6 +45,7 @@ impl Completer for ReplCompleter {
|
|||||||
.filter(|cmd| {
|
.filter(|cmd| {
|
||||||
cmd.is_valid(state)
|
cmd.is_valid(state)
|
||||||
&& (command_filter.len() == 1 || cmd.name.starts_with(&command_filter[..2]))
|
&& (command_filter.len() == 1 || cmd.name.starts_with(&command_filter[..2]))
|
||||||
|
&& (cmd.name != ".reasoning" || model_has_reasoning)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let commands = fuzzy_filter(commands, |v| v.name, &command_filter);
|
let commands = fuzzy_filter(commands, |v| v.name, &command_filter);
|
||||||
|
|||||||
+148
-90
@@ -1,15 +1,13 @@
|
|||||||
mod completer;
|
mod completer;
|
||||||
mod highlighter;
|
mod highlighter;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
|
mod replay;
|
||||||
|
|
||||||
use self::completer::ReplCompleter;
|
use self::completer::ReplCompleter;
|
||||||
use self::highlighter::ReplHighlighter;
|
use self::highlighter::ReplHighlighter;
|
||||||
use self::prompt::ReplPrompt;
|
use self::prompt::ReplPrompt;
|
||||||
|
|
||||||
use crate::client::{
|
use crate::client::{call_chat_completions, call_chat_completions_streaming, init_client, oauth};
|
||||||
Message, MessageRole, call_chat_completions, call_chat_completions_streaming, init_client,
|
|
||||||
oauth,
|
|
||||||
};
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
|
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
|
||||||
macro_execute,
|
macro_execute,
|
||||||
@@ -52,7 +50,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
|||||||
4. Continue with the next pending item now. Call tools immediately."
|
4. Continue with the next pending item now. Call tools immediately."
|
||||||
};
|
};
|
||||||
|
|
||||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
|
static REPL_COMMANDS: LazyLock<[ReplCommand; 58]> = LazyLock::new(|| {
|
||||||
[
|
[
|
||||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||||
@@ -71,6 +69,26 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
|
|||||||
"Authenticate with an MCP server via OAuth",
|
"Authenticate with an MCP server via OAuth",
|
||||||
AssertState::pass(),
|
AssertState::pass(),
|
||||||
),
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".mcp enable",
|
||||||
|
"Enable a single MCP server in the current context",
|
||||||
|
AssertState::pass(),
|
||||||
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".mcp disable",
|
||||||
|
"Disable a single MCP server in the current context",
|
||||||
|
AssertState::pass(),
|
||||||
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".tool enable",
|
||||||
|
"Enable a single tool in the current context",
|
||||||
|
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||||
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".tool disable",
|
||||||
|
"Disable a single tool in the current context",
|
||||||
|
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||||
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".edit config",
|
".edit config",
|
||||||
"Modify configuration file",
|
"Modify configuration file",
|
||||||
@@ -125,6 +143,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
|
|||||||
"Clear session messages",
|
"Clear session messages",
|
||||||
AssertState::True(StateFlags::SESSION),
|
AssertState::True(StateFlags::SESSION),
|
||||||
),
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".undo",
|
||||||
|
"Undo the last exchange and restore the prompt",
|
||||||
|
AssertState::True(StateFlags::SESSION),
|
||||||
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".compress session",
|
".compress session",
|
||||||
"Compress session messages",
|
"Compress session messages",
|
||||||
@@ -150,6 +173,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
|
|||||||
"Exit active session",
|
"Exit active session",
|
||||||
AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),
|
AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),
|
||||||
),
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".fork",
|
||||||
|
"Fork the active session into a new named copy",
|
||||||
|
AssertState::True(StateFlags::SESSION),
|
||||||
|
),
|
||||||
ReplCommand::new(".agent", "Use an agent", AssertState::bare()),
|
ReplCommand::new(".agent", "Use an agent", AssertState::bare()),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".starter",
|
".starter",
|
||||||
@@ -254,11 +282,21 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
|
|||||||
),
|
),
|
||||||
ReplCommand::new(".copy", "Copy last response", AssertState::pass()),
|
ReplCommand::new(".copy", "Copy last response", AssertState::pass()),
|
||||||
ReplCommand::new(".set", "Modify runtime settings", AssertState::pass()),
|
ReplCommand::new(".set", "Modify runtime settings", AssertState::pass()),
|
||||||
|
ReplCommand::new(
|
||||||
|
".reasoning",
|
||||||
|
"Set the reasoning effort level for the current model",
|
||||||
|
AssertState::pass(),
|
||||||
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".delete",
|
".delete",
|
||||||
"Delete roles, sessions, RAGs, or agents",
|
"Delete roles, sessions, RAGs, or agents",
|
||||||
AssertState::pass(),
|
AssertState::pass(),
|
||||||
),
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".list",
|
||||||
|
"List roles, sessions, agents, RAGs, macros, skills, tools, or MCP servers",
|
||||||
|
AssertState::pass(),
|
||||||
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".vault",
|
".vault",
|
||||||
"View or modify the Coyote vault",
|
"View or modify the Coyote vault",
|
||||||
@@ -322,54 +360,16 @@ Type ".help" for additional help.
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let (messages_snapshot, compressed_count) = {
|
let (compressed, active) = {
|
||||||
let ctx = self.ctx.read();
|
let ctx = self.ctx.read();
|
||||||
if let Some(session) = &ctx.session {
|
match &ctx.session {
|
||||||
let msgs: Vec<Message> = session
|
Some(session) => replay::snapshot(session),
|
||||||
.messages()
|
None => (Vec::new(), Vec::new()),
|
||||||
.iter()
|
|
||||||
.filter(|m| !m.role.is_system())
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let compressed = session.compressed_messages().len();
|
|
||||||
(msgs, compressed)
|
|
||||||
} else {
|
|
||||||
(vec![], 0)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if !compressed.is_empty() || !active.is_empty() {
|
||||||
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
|
||||||
let app = Arc::clone(&self.ctx.read().app.config);
|
let app = Arc::clone(&self.ctx.read().app.config);
|
||||||
if compressed_count > 0 {
|
replay::render(app.as_ref(), &compressed, &active)?;
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
dimmed_text(&format!(
|
|
||||||
"({compressed_count} earlier messages not shown; compressed for context)"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
for message in &messages_snapshot {
|
|
||||||
match message.role {
|
|
||||||
MessageRole::User => {
|
|
||||||
if let Some(text) = message.content.as_text() {
|
|
||||||
println!("{}", dimmed_text("You:"));
|
|
||||||
println!("{text}");
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MessageRole::Assistant => {
|
|
||||||
if let Some(text) = message.content.as_text() {
|
|
||||||
app.print_markdown(text)?;
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
|
|
||||||
println!();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +390,10 @@ Type ".help" for additional help.
|
|||||||
if exit {
|
if exit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if let Some(text) = self.ctx.write().pending_prefill.take() {
|
||||||
|
self.editor
|
||||||
|
.run_edit_commands(&[EditCommand::InsertString(text)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
render_error(err);
|
render_error(err);
|
||||||
@@ -659,14 +663,69 @@ pub async fn run_repl_command(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
println!("Authentication saved.");
|
println!("Authentication saved.");
|
||||||
|
if ctx.app.config.mcp_server_support {
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
ctx.bootstrap_tools(
|
||||||
|
app.as_ref(),
|
||||||
|
true,
|
||||||
|
abort_signal.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if ctx.tool_scope.mcp_runtime.get(server_name).is_some()
|
||||||
|
{
|
||||||
|
println!(
|
||||||
|
"✓ MCP server '{server_name}' started and attached to the current context."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"MCP server '{server_name}' is not enabled in the current context. \
|
||||||
|
Run `.mcp enable {server_name}` to attach it."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"enable" | "disable" => {
|
||||||
|
if rest.is_empty() {
|
||||||
|
println!("Usage: .mcp {sub} <server_name>");
|
||||||
|
} else {
|
||||||
|
ctx.toggle_mcp_server(sub, rest, abort_signal.clone())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => unknown_command()?,
|
_ => unknown_command()?,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => println!("Usage: .mcp auth <server_name>"),
|
None => println!(
|
||||||
|
r#"Usage:
|
||||||
|
.mcp auth <server_name> # Authenticate with an MCP server via OAuth
|
||||||
|
.mcp enable <server_name> # Enable a single MCP server in the current context
|
||||||
|
.mcp disable <server_name> # Disable a single MCP server in the current context"#
|
||||||
|
),
|
||||||
|
},
|
||||||
|
".tool" => match args {
|
||||||
|
Some(args) => {
|
||||||
|
let mut parts = args.splitn(2, char::is_whitespace);
|
||||||
|
let sub = parts.next().unwrap_or("").trim();
|
||||||
|
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||||
|
match sub {
|
||||||
|
"enable" | "disable" => {
|
||||||
|
if rest.is_empty() {
|
||||||
|
println!("Usage: .tool {sub} <name>");
|
||||||
|
} else {
|
||||||
|
ctx.toggle_tool(sub, rest)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unknown_command()?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => println!(
|
||||||
|
r#"Usage:
|
||||||
|
.tool enable <name> # Enable a single tool in the current context
|
||||||
|
.tool disable <name> # Disable a single tool in the current context"#
|
||||||
|
),
|
||||||
},
|
},
|
||||||
".prompt" => match args {
|
".prompt" => match args {
|
||||||
Some(text) => {
|
Some(text) => {
|
||||||
@@ -760,44 +819,8 @@ pub async fn run_repl_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(session) = &ctx.session {
|
if let Some(session) = &ctx.session {
|
||||||
let messages_snapshot: Vec<Message> = session
|
let (compressed, active) = replay::snapshot(session);
|
||||||
.messages()
|
replay::render(app.as_ref(), &compressed, &active)?;
|
||||||
.iter()
|
|
||||||
.filter(|m| !m.role.is_system())
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let compressed_count = session.compressed_messages().len();
|
|
||||||
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
|
||||||
if compressed_count > 0 {
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
dimmed_text(&format!(
|
|
||||||
"({compressed_count} earlier messages not shown — compressed for context)"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
for message in &messages_snapshot {
|
|
||||||
match message.role {
|
|
||||||
MessageRole::User => {
|
|
||||||
if let Some(text) = message.content.as_text() {
|
|
||||||
println!("{}", dimmed_text("You:"));
|
|
||||||
println!("{text}");
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MessageRole::Assistant => {
|
|
||||||
if let Some(text) = message.content.as_text() {
|
|
||||||
app.print_markdown(text)?;
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
".install" => {
|
".install" => {
|
||||||
@@ -853,6 +876,10 @@ pub async fn run_repl_command(
|
|||||||
let app = Arc::clone(&ctx.app.config);
|
let app = Arc::clone(&ctx.app.config);
|
||||||
ctx.use_agent(app.as_ref(), agent_name, session_name, abort_signal.clone())
|
ctx.use_agent(app.as_ref(), agent_name, session_name, abort_signal.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
if let Some(session) = &ctx.session {
|
||||||
|
let (compressed, active) = replay::snapshot(session);
|
||||||
|
replay::render(app.as_ref(), &compressed, &active)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#)
|
println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#)
|
||||||
@@ -884,6 +911,9 @@ pub async fn run_repl_command(
|
|||||||
ctx.app.config.print_markdown(&banner)?;
|
ctx.app.config.print_markdown(&banner)?;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
".fork" => {
|
||||||
|
ctx.fork_session(args)?;
|
||||||
|
}
|
||||||
".save" => match split_first_arg(args) {
|
".save" => match split_first_arg(args) {
|
||||||
Some(("role", name)) => {
|
Some(("role", name)) => {
|
||||||
ctx.save_role(name)?;
|
ctx.save_role(name)?;
|
||||||
@@ -966,6 +996,15 @@ pub async fn run_repl_command(
|
|||||||
println!(r#"Usage: .empty session"#)
|
println!(r#"Usage: .empty session"#)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
".undo" => {
|
||||||
|
if let Some(name) = graph::active_agent_graph_name(ctx) {
|
||||||
|
bail!(
|
||||||
|
"Graph-based agent '{name}' does not support .undo. \
|
||||||
|
The graph manages its own state."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ctx.undo_last_exchange()?;
|
||||||
|
}
|
||||||
".rebuild" => match args {
|
".rebuild" => match args {
|
||||||
Some("rag") => {
|
Some("rag") => {
|
||||||
ctx.rebuild_rag(abort_signal.clone()).await?;
|
ctx.rebuild_rag(abort_signal.clone()).await?;
|
||||||
@@ -1052,6 +1091,15 @@ pub async fn run_repl_command(
|
|||||||
println!("Usage: .set <key> <value>...")
|
println!("Usage: .set <key> <value>...")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
".reasoning" => match args {
|
||||||
|
Some(level) => {
|
||||||
|
let set_args = format!("reasoning_effort {level}");
|
||||||
|
ctx.update(&set_args, abort_signal).await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!("Usage: .reasoning <level>")
|
||||||
|
}
|
||||||
|
},
|
||||||
".delete" => match args {
|
".delete" => match args {
|
||||||
Some(args) => {
|
Some(args) => {
|
||||||
ctx.delete(args)?;
|
ctx.delete(args)?;
|
||||||
@@ -1060,6 +1108,16 @@ pub async fn run_repl_command(
|
|||||||
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
|
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
".list" => match args {
|
||||||
|
Some(args) => {
|
||||||
|
ctx.list_assets(args.trim())?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!(
|
||||||
|
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers>"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
".copy" => {
|
".copy" => {
|
||||||
let output = match ctx
|
let output = match ctx
|
||||||
.last_message
|
.last_message
|
||||||
@@ -1582,8 +1640,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_has_50_entries() {
|
fn repl_commands_has_58_entries() {
|
||||||
assert_eq!(REPL_COMMANDS.len(), 50);
|
assert_eq!(REPL_COMMANDS.len(), 58);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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() {
|
match resolve_vault_password_file() {
|
||||||
Some(password_file) if password_file.exists() => {
|
Some(password_file) if password_file.exists() => {
|
||||||
let dest_path = host_to_sandbox_path(&password_file, &home_dir, cfg!(windows))?;
|
let dest_path = host_to_sandbox_path(&password_file, &home_dir, cfg!(windows))?;
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ use tokio::time::sleep;
|
|||||||
|
|
||||||
pub async fn tail_logs(no_color: bool) {
|
pub async fn tail_logs(no_color: bool) {
|
||||||
let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)\]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap();
|
let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)\]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap();
|
||||||
let file_path = paths::log_path();
|
let file_path = paths::log_file();
|
||||||
let file = File::open(&file_path).expect("Cannot open file");
|
let file = File::open(&file_path).expect("Cannot open file");
|
||||||
let mut reader = BufReader::new(file);
|
let mut reader = BufReader::new(file);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ fn apply_sandboxed_home_translation(provider_def: &mut LocalProvider) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(translated) = paths::translate_sandboxed_home_path(pf) else {
|
let Some(translated) = paths::translate_sandboxed_home_dir(pf) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user