Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)
|
|
||||||
Generated
+44
@@ -1016,6 +1016,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"
|
||||||
@@ -1457,6 +1463,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 +3053,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 +3595,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 +4447,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"
|
||||||
|
|||||||
@@ -107,6 +107,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,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, 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
|
||||||
|
|||||||
+54
-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.
|
||||||
@@ -31,7 +33,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 +136,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 +189,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 +200,18 @@ memory_cap_with_tools: null # Char cap for injected memory when function ca
|
|||||||
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000).
|
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 +221,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 +348,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 \
|
||||||
|
.
|
||||||
|
|||||||
+336
-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
|
||||||
@@ -1618,6 +1827,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 +1863,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 +1897,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 +1906,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 +1915,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 +1924,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 +1933,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 +1942,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 +1951,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 +1960,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 +1978,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 +2017,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 +2107,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 +2117,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 +2127,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 +2137,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 +2147,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 +2157,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
|
||||||
|
|||||||
+162
-110
@@ -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,185 @@ 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
|
|
||||||
#[arg(long)]
|
|
||||||
pub no_memory: bool,
|
|
||||||
/// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!)
|
|
||||||
#[arg(long)]
|
|
||||||
pub dangerously_skip_permissions: bool,
|
|
||||||
/// Bootstrap a memory marker so coyote begins loading memory next run
|
|
||||||
#[arg(long, value_name = "SCOPE", value_enum)]
|
|
||||||
pub init_memory: Option<MemoryScope>,
|
|
||||||
/// Display the message without sending it
|
/// 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.
|
||||||
|
|||||||
+28
-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
|
||||||
}
|
}
|
||||||
@@ -575,8 +583,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 +608,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,6 +648,8 @@ 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,
|
||||||
@@ -732,6 +745,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 +780,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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -88,6 +92,7 @@ pub struct AppConfig {
|
|||||||
|
|
||||||
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 +104,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 +149,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,
|
||||||
@@ -162,6 +171,7 @@ impl Default for AppConfig {
|
|||||||
|
|
||||||
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 +185,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 +230,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,
|
||||||
@@ -238,6 +252,7 @@ impl AppConfig {
|
|||||||
|
|
||||||
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 +265,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 +286,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 +329,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 +349,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 +380,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()))?;
|
||||||
@@ -421,6 +464,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-6
@@ -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;
|
||||||
@@ -43,7 +44,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 +63,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 +141,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 +184,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 +245,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,
|
||||||
@@ -319,6 +323,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,
|
||||||
@@ -474,7 +481,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 +596,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"))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+861
-19
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -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",
|
||||||
@@ -261,7 +263,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +403,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 +735,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 +795,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 +830,13 @@ impl RoleLike for Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_reasoning_effort(&mut self, value: Option<String>) {
|
||||||
|
if self.reasoning_effort != value {
|
||||||
|
self.reasoning_effort = value;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
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;
|
||||||
|
|||||||
+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);
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-19
@@ -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()));
|
||||||
@@ -1890,4 +1918,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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+75
-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,
|
||||||
@@ -365,6 +370,15 @@ async fn run(
|
|||||||
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 +388,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 +411,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 +602,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 +769,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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-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),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,7 +151,6 @@ 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 => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+166
-4
@@ -52,7 +52,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; 57]> = 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 +71,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 +145,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",
|
||||||
@@ -254,11 +279,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",
|
||||||
@@ -390,6 +425,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 +698,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) => {
|
||||||
@@ -853,6 +947,46 @@ 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 messages_snapshot: Vec<Message> = session
|
||||||
|
.messages()
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !m.role.is_system())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let compressed_count = session.compressed_messages().len();
|
||||||
|
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
||||||
|
if compressed_count > 0 {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
dimmed_text(&format!(
|
||||||
|
"({compressed_count} earlier messages not shown — compressed for context)"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
for message in &messages_snapshot {
|
||||||
|
match message.role {
|
||||||
|
MessageRole::User => {
|
||||||
|
if let Some(text) = message.content.as_text() {
|
||||||
|
println!("{}", dimmed_text("You:"));
|
||||||
|
println!("{text}");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageRole::Assistant => {
|
||||||
|
if let Some(text) = message.content.as_text() {
|
||||||
|
app.print_markdown(text)?;
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#)
|
println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#)
|
||||||
@@ -966,6 +1100,15 @@ pub async fn run_repl_command(
|
|||||||
println!(r#"Usage: .empty session"#)
|
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 +1195,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 +1212,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 +1744,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_has_50_entries() {
|
fn repl_commands_has_57_entries() {
|
||||||
assert_eq!(REPL_COMMANDS.len(), 50);
|
assert_eq!(REPL_COMMANDS.len(), 57);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -388,6 +388,26 @@ fn copy_host_files(name: &str) -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let oauth_tokens_dir = paths::oauth_tokens_dir();
|
||||||
|
if oauth_tokens_dir.exists() {
|
||||||
|
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_oauth_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