mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70743997ca | ||
|
|
503b82b87a | ||
|
|
64f97bdd87 | ||
|
|
a3a8723bbd | ||
|
|
1cc84601d9 | ||
|
|
f266be5c71 | ||
|
|
76b4c05994 | ||
|
|
e8ee93d4d3 | ||
|
|
793db1ff25 | ||
|
|
969931a10c | ||
|
|
623d56cbf8 | ||
|
|
dcc5bdb91d | ||
|
|
690acecf77 | ||
|
|
96be850cc3 | ||
|
|
a448a8a611 | ||
|
|
f2b0292b1f | ||
|
|
c863634dc5 |
No files matched your search
@@ -25,6 +25,12 @@
|
||||
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
|
||||
|
||||
# Xiaomi MiMo Token Plan (https://platform.xiaomimimo.com/docs/zh-CN/price/tokenplan/quick-access)
|
||||
# DEEPSEEK_PROVIDER=xiaomi
|
||||
# MIMO_API_KEY=tp-YOUR_TOKEN_PLAN_KEY # Token Plan key format: tp-xxxxx
|
||||
# MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 # cn / sgp / ams cluster
|
||||
# MIMO_MODEL=mimo-v2.5-pro # mimo-v2.5-pro | mimo-v2.5
|
||||
|
||||
# Logging
|
||||
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
|
||||
# TUI's lightweight verbose logs for info/debug/trace directives.
|
||||
|
||||
+69
-3
@@ -5,10 +5,27 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.8.47] - 2026-05-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Closed-loop verification gate.** After every side-effect tool call
|
||||
(write, edit, apply_patch, exec_shell), the engine re-checks the claim.
|
||||
Results annotated with `[VERIFY PASS]` or `[VERIFY FAIL]` enter the
|
||||
session verification ledger. Enabled by default; configurable via
|
||||
`[verification]` in `config.toml`.
|
||||
- **All native tools loaded upfront.** Removed deferred loading policy.
|
||||
Every tool is visible from turn one instead of requiring `tool_search`
|
||||
discovery. 80+ tools in catalog with ~1% context overhead.
|
||||
- **`response_format` field on MessageRequest.** Supports DeepSeek JSON
|
||||
Output mode for structured responses when needed.
|
||||
- **Runtime goal tools.** `create_goal`, `get_goal`, and `update_goal`
|
||||
provide bounded continuation gates with objective tracking, token
|
||||
budgets, and LLM-as-judge completion verification (#2199).
|
||||
- **Session failure classifier.** A redacted synthetic classifier
|
||||
diagnoses session failures for faster triage (#2022).
|
||||
- **DuckDuckGo as default web search backend.** Switched from Bing to
|
||||
DuckDuckGo as the default search provider (#2132).
|
||||
- **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow
|
||||
selection in the composer input box, with Ctrl+C copy and Ctrl+X cut
|
||||
support. Home, End, Ctrl+A, and Ctrl+E now clear the selection (#2228).
|
||||
@@ -22,9 +39,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
both npm install scripts and Rust self-updater (#2222).
|
||||
- **[✓] completion markers.** Checklist, plan, and tool completion
|
||||
markers now render as `[✓]` instead of `[x]` (#1935).
|
||||
- **Xiaomi MiMo provider support.** MiMo is now a first-class provider
|
||||
with API-key auth and secret-store integration (#2240).
|
||||
- **Global AGENTS.md fallback.** CodeWhale now reads `~/.agents/AGENTS.md`
|
||||
as a vendor-neutral project instructions fallback (#2156).
|
||||
- **DeepSeek V4 Pro pricing permanent.** The 75% discount is now
|
||||
permanent, reflected in the pricing model (#1937).
|
||||
- **`/new` session command.** `/new [--force]` starts a fresh saved session
|
||||
from inside the TUI, distinct from `/clear`. Respects active work
|
||||
blockers; `--force` discards unsaved input (#2235).
|
||||
- **Docker toolbox contract docs.** Added custom-image contract,
|
||||
examples, and toolbox documentation (#2217).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Constitution: 'begin with an A' clarified.** Article I now frames
|
||||
this as a grade metaphor — the A+ is awarded in advance, and the duty
|
||||
is to be worthy of a grade already given.
|
||||
- **Article V updated with structural verification note.** The
|
||||
verification gate is now a structural part of the Constitution.
|
||||
- **Project context loading now logs the source file.** (#2227)
|
||||
- **macOS onboarding and empty-state layout pinned to top** instead
|
||||
of vertically centered (#1837).
|
||||
@@ -32,11 +65,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
prefer `~/.codewhale` with `~/.deepseek` fallback (#2231).
|
||||
- **READMEs updated for the CodeWhale rename.** All three READMEs now
|
||||
reference canonical `~/.codewhale` paths.
|
||||
- **Reasoning content stays English regardless of locale.** Hidden
|
||||
`reasoning_content` blocks now respect the English-only policy
|
||||
regardless of the UI locale setting (#1842/#1843).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deadlock when spawning multiple concurrent sub-agents.** Replaced
|
||||
`RwLock`-based serialisation with a `Semaphore(1)` (#1856).
|
||||
- **Provider picker scroll visibility.** The `/provider` modal now
|
||||
sizes dynamically, scrolls to keep the selected row visible, and
|
||||
wraps Up/Down navigation between first and last providers (#2241).
|
||||
- **Auto model state restored on session load.** The auto model
|
||||
selection survives session save/restore cycles (#1797).
|
||||
- **Cache-inspect prefix hashing includes tool catalog.** Fixes
|
||||
prefix-cache misses when tools change between turns (#1818).
|
||||
- **Insecure HTTP guard for LAN providers.** Added
|
||||
`DEEPSEEK_ALLOW_INSECURE_HTTP` env var for local vLLM endpoints
|
||||
on `http://` (#1656).
|
||||
- **Large tool outputs compacted to artifact receipts on persist.**
|
||||
Prevents session bloat from large stdout/stderr payloads (#2021).
|
||||
- **Steered/queued messages now render in correct transcript order.**
|
||||
`steer_user_message` now flushes the active cell before inserting (#2225).
|
||||
- **Session save test updated for managed sessions directory.** (#2223).
|
||||
@@ -44,6 +92,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`Failed` instead of `Completed` when the loop guard trips (#1859).
|
||||
- **DEEPSEEK_YOLO env honoured on startup.** The `--yolo` flag is now
|
||||
correctly merged with the `DEEPSEEK_YOLO` environment variable (#1870).
|
||||
- **Clippy warnings resolved.** (#2237).
|
||||
- **Windows alt-screen logging suppressed.** Verbose CLI logging no
|
||||
longer leaks into the TUI on Windows alt-screen (#1910).
|
||||
- **PDF and OCR reads no longer block the async runtime.** `read_file`
|
||||
on PDF and image files now runs extraction inside `spawn_blocking`,
|
||||
preventing the tokio worker pool from stalling on large documents.
|
||||
- **Sidebar hover tooltip contrast fixed.** Tooltip foreground changed
|
||||
from pale gray (`TEXT_MUTED`) to deep navy (`DEEPSEEK_INK`) for
|
||||
readable contrast against the amber warning background.
|
||||
|
||||
### Community
|
||||
|
||||
@@ -55,7 +112,15 @@ Thanks to contributors whose PRs landed in this release:
|
||||
**@IIzzaya** (#1935),
|
||||
**@PurplePulse** (#1837),
|
||||
**@cyq1017** (#1967),
|
||||
**@knqiufan** (#1906).
|
||||
**@knqiufan** (#1906),
|
||||
**@Colorful-glassblock** (#1937),
|
||||
**@hongqitai** (#2237),
|
||||
**@EmiyaKiritsugu3** (#1852),
|
||||
**@aboimpinto** (#1910),
|
||||
**@HUQIANTAO** (#2240),
|
||||
**@mvanhorn** (#2156),
|
||||
**@LING71671** (#1797),
|
||||
and **@reidliu41** (#2241, #2235).
|
||||
|
||||
## [0.8.46] - 2026-05-26
|
||||
|
||||
@@ -5018,7 +5083,8 @@ Welcome — and thank you.
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.47...HEAD
|
||||
[0.8.47]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...v0.8.47
|
||||
[0.8.46]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...v0.8.46
|
||||
[0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45
|
||||
[0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44
|
||||
|
||||
Generated
+14
-14
@@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21"
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-agent"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"codewhale-config",
|
||||
"serde",
|
||||
@@ -811,7 +811,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-app-server"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -836,7 +836,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-cli"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -862,7 +862,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-config"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codewhale-secrets",
|
||||
@@ -875,7 +875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-core"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -893,7 +893,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-execpolicy"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codewhale-protocol",
|
||||
@@ -902,7 +902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-hooks"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -916,7 +916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-mcp"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -925,7 +925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-protocol"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -933,7 +933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-secrets"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"keyring",
|
||||
@@ -946,7 +946,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-state"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -958,7 +958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tools"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -971,7 +971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tui"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -1037,7 +1037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tui-core"
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.46"
|
||||
version = "0.8.47"
|
||||
edition = "2024"
|
||||
# Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the
|
||||
# codebase relies on extensively. Cargo enforces this so users on older
|
||||
|
||||
@@ -428,6 +428,19 @@ description: DeepSeek にカスタムワークフローを実行させたいと
|
||||
- **Hafeez Pizofreude** — `fetch_url` の SSRF 保護と Star History チャート
|
||||
- **Unic (YuniqueUnic)** — スキーマ駆動の設定 UI(TUI + Web)
|
||||
- **Jason** — SSRF セキュリティの強化
|
||||
- **[Fire-dtx](https://github.com/Fire-dtx)** — 並行サブエージェントのデッドロック修正 (#1856)
|
||||
- **[imkingjh999](https://github.com/imkingjh999)** — コンポーザーのテキスト選択とコピー/カット (#2228)
|
||||
- **[harvey2011888](https://github.com/harvey2011888)** — ループガード停止結果の修正 (#1859)
|
||||
- **[victorcheng2333](https://github.com/victorcheng2333)** — DEEPSEEK_YOLO 環境変数マージ修正 (#1870)
|
||||
- **[IIzzaya](https://github.com/IIzzaya)** — [✓] 完了マーカー (#1935)
|
||||
- **[PurplePulse](https://github.com/PurplePulse)** — macOS オンボーディングレイアウト修正 (#1837)
|
||||
- **[cyq1017](https://github.com/cyq1017)** — /config ビューでのベース URL 表示 (#1967)
|
||||
- **[knqiufan](https://github.com/knqiufan)** — 文字折り返しなしのトランスクリプトコピー (#1906)
|
||||
- **[Colorful-glassblock](https://github.com/Colorful-glassblock)** — DeepSeek V4 Pro 価格の恒久化 (#1937)
|
||||
- **[hongqitai](https://github.com/hongqitai)** — Clippy 警告の修正 (#2237)
|
||||
- **[EmiyaKiritsugu3](https://github.com/EmiyaKiritsugu3)** — DEEPSEEK.md プロジェクトコンテキスト (#1852)
|
||||
- **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi MiMo プロバイダーサポート (#2240)
|
||||
- **[mvanhorn](https://github.com/mvanhorn)** — グローバル AGENTS.md ベンダーニュートラルフォールバック (#2156)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ evidence, never declare success on faith), a coordination legacy (Article
|
||||
VI — leave the workspace legible for the next intelligence), and a
|
||||
primacy-of-truth clause (Article II — no lower rule may override it).
|
||||
|
||||
Article V is now structural. A closed-loop verification gate runs after every
|
||||
side-effect tool call — write, edit, patch, shell — and re-checks the claim
|
||||
before the result enters the session stream. Results carry `[VERIFY PASS]` or
|
||||
`[VERIFY FAIL]` annotations. No more taking the model's word for it.
|
||||
|
||||
DeepSeek V4's prefix caching makes this practical. The Constitution is long
|
||||
and detailed, but once cached it costs roughly 100× less per turn than a
|
||||
cold read. The model references it recursively — peeking, scanning, and
|
||||
@@ -331,6 +336,10 @@ VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deeps
|
||||
# Trusted LAN vLLM over HTTP
|
||||
DEEPSEEK_ALLOW_INSECURE_HTTP=1 VLLM_BASE_URL="http://192.168.0.110:8000/v1" codewhale --provider vllm --model deepseek-v4-flash
|
||||
|
||||
# Xiaomi MiMo
|
||||
codewhale auth set --provider xiaomi --api-key "YOUR_MIMO_API_KEY"
|
||||
codewhale --provider xiaomi --model deepseek-v4-pro
|
||||
|
||||
# Self-hosted Ollama
|
||||
ollama pull codewhale-coder:1.3b
|
||||
codewhale --provider ollama --model codewhale-coder:1.3b
|
||||
@@ -477,7 +486,7 @@ Key environment variables:
|
||||
| `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` |
|
||||
| `DEEPSEEK_MODEL` | Default model |
|
||||
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` |
|
||||
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` |
|
||||
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `xiaomi`, `sglang`, `vllm`, `ollama` |
|
||||
| `DEEPSEEK_PROFILE` | Config profile name |
|
||||
| `DEEPSEEK_MEMORY` | Set to `on` to enable user memory |
|
||||
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks |
|
||||
@@ -600,7 +609,7 @@ This project ships with help from a growing community of contributors:
|
||||
- **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686)
|
||||
- **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — model ID case-sensitivity compatibility report (#729)
|
||||
- **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — stale `working...` state bug report, Windows clipboard fallback, MCP Streamable HTTP session fixes, and Homebrew tap automation (#738, #850, #1643, #1631)
|
||||
- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, and DeepSeek model completions (#863, #870, #921, #1078, #1603, #1628, #1601)
|
||||
- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, approval flow simplification, model picker Esc fix, user message highlighting, provider picker scroll, and `/new` session command (#863, #870, #921, #1078, #1603, #1628, #1601, #2143, #2056, #1995, #2241, #2235)
|
||||
- **[xieshutao](https://github.com/xieshutao)** — plain Markdown skill fallback (#869)
|
||||
- **[GK012](https://github.com/GK012)** — npm wrapper `--version` fallback (#885)
|
||||
- **[y0sif](https://github.com/y0sif)** — parent turn-loop wakeup after direct child sub-agent completion (#901)
|
||||
@@ -633,7 +642,7 @@ This project ships with help from a growing community of contributors:
|
||||
- **[Aitensa](https://github.com/Aitensa)** — CJK wrapping propagation for diff and pager output (#1622)
|
||||
- **[qiyan233](https://github.com/qiyan233)** — legacy DeepSeek CN provider alias compatibility (#1645)
|
||||
- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report and clipboard-init fix (#1772, #1773)
|
||||
- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, and runtime log follow-ups (#1774, #1776, #1748, #1749, #1782, #1783)
|
||||
- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, runtime log follow-ups, taskbar progress, animated title spinner, and configurable completion sound (#1774, #1776, #1748, #1749, #1782, #1783, #1871, #1910)
|
||||
- **[LeoLin990405](https://github.com/LeoLin990405)** — provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes (#1740, #1743, #1742, #1744)
|
||||
- **[nightt5879](https://github.com/nightt5879)** — Ctrl+C prompt restore fix (#1764)
|
||||
- **[donglovejava](https://github.com/donglovejava)** — paste @file consolidation, CJK panic fix, user feedback, RLM routing, edit_file retry (#2154–#2168)
|
||||
@@ -646,6 +655,19 @@ This project ships with help from a growing community of contributors:
|
||||
- **[mrluanma](https://github.com/mrluanma)** — Metaso search provider (#2059)
|
||||
- **[Lellansin](https://github.com/Lellansin)** — skip config merge at home dir (#2055)
|
||||
- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — update release channels (#2145)
|
||||
- **[Fire-dtx](https://github.com/Fire-dtx)** — deadlock fix for concurrent sub-agents (#1856)
|
||||
- **[imkingjh999](https://github.com/imkingjh999)** — composer text selection with copy/cut (#2228)
|
||||
- **[harvey2011888](https://github.com/harvey2011888)** — loop guard halt outcome fix (#1859)
|
||||
- **[victorcheng2333](https://github.com/victorcheng2333)** — DEEPSEEK_YOLO env merge fix (#1870)
|
||||
- **[IIzzaya](https://github.com/IIzzaya)** — [✓] completion markers (#1935)
|
||||
- **[PurplePulse](https://github.com/PurplePulse)** — macOS onboarding layout pinning (#1837)
|
||||
- **[cyq1017](https://github.com/cyq1017)** — configurable base URL in /config view (#1967)
|
||||
- **[knqiufan](https://github.com/knqiufan)** — copy transcript without visual-wrap newlines (#1906)
|
||||
- **[Colorful-glassblock](https://github.com/Colorful-glassblock)** — DeepSeek V4 Pro pricing permanent (#1937)
|
||||
- **[hongqitai](https://github.com/hongqitai)** — clippy warnings fix (#2237)
|
||||
- **[EmiyaKiritsugu3](https://github.com/EmiyaKiritsugu3)** — DEEPSEEK.md project context (#1852)
|
||||
- **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi MiMo provider support (#2240)
|
||||
- **[mvanhorn](https://github.com/mvanhorn)** — global AGENTS.md vendor-neutral fallback (#2156)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-1
@@ -537,7 +537,7 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技
|
||||
- **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686)
|
||||
- **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — 模型 ID 大小写兼容性报告 (#729)
|
||||
- **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — `working...` 卡死状态 Bug 报告和 Windows 剪贴板兜底修复 (#738, #850)
|
||||
- **[reidliu41](https://github.com/reidliu41)** — 退出后的恢复提示、工作区信任持久化、Ollama provider 支持,以及思考块流式终结修复 (#863, #870, #921, #1078)
|
||||
- **[reidliu41](https://github.com/reidliu41)** — 退出后的恢复提示、工作区信任持久化、Ollama provider 支持、思考块流式终结修复、审批流程简化、模型选择器 Esc 修复、用户消息高亮、provider 选择器滚动、`/new` 会话命令 (#863, #870, #921, #1078, #2143, #2056, #1995, #2241, #2235)
|
||||
- **[xieshutao](https://github.com/xieshutao)** — 纯 Markdown skill 兜底解析 (#869)
|
||||
- **[GK012](https://github.com/GK012)** — npm wrapper 的 `--version` 兜底 (#885)
|
||||
- **[y0sif](https://github.com/y0sif)** — 直接子智能体完成后唤醒父级 turn loop (#901)
|
||||
@@ -573,6 +573,19 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技
|
||||
- **[mrluanma](https://github.com/mrluanma)** — Metaso 搜索提供商 (#2059)
|
||||
- **[Lellansin](https://github.com/Lellansin)** — 主目录下跳过配置合并 (#2055)
|
||||
- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — 更新发布渠道 (#2145)
|
||||
- **[Fire-dtx](https://github.com/Fire-dtx)** — 并发子代理死锁修复 (#1856)
|
||||
- **[imkingjh999](https://github.com/imkingjh999)** — 输入框文本选择与复制/剪切 (#2228)
|
||||
- **[harvey2011888](https://github.com/harvey2011888)** — 循环守卫停止状态修复 (#1859)
|
||||
- **[victorcheng2333](https://github.com/victorcheng2333)** — DEEPSEEK_YOLO 环境变量合并修复 (#1870)
|
||||
- **[IIzzaya](https://github.com/IIzzaya)** — [✓] 完成标记 (#1935)
|
||||
- **[PurplePulse](https://github.com/PurplePulse)** — macOS 引导布局优化 (#1837)
|
||||
- **[cyq1017](https://github.com/cyq1017)** — /config 视图显示 base URL (#1967)
|
||||
- **[knqiufan](https://github.com/knqiufan)** — 复制文本去除自动换行符 (#1906)
|
||||
- **[Colorful-glassblock](https://github.com/Colorful-glassblock)** — DeepSeek V4 Pro 定价永久化 (#1937)
|
||||
- **[hongqitai](https://github.com/hongqitai)** — Clippy 警告修复 (#2237)
|
||||
- **[EmiyaKiritsugu3](https://github.com/EmiyaKiritsugu3)** — DEEPSEEK.md 项目上下文 (#1852)
|
||||
- **[HUQIANTAO](https://github.com/HUQIANTAO)** — 小米 MiMo 提供商支持 (#2240)
|
||||
- **[mvanhorn](https://github.com/mvanhorn)** — 全局 AGENTS.md 厂商中立回退 (#2156)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+21
-1
@@ -17,7 +17,7 @@
|
||||
# toggle without having to re-enter keys. Top-level `api_key` / `base_url` are
|
||||
# still read as DeepSeek defaults when `[providers.deepseek]` is absent
|
||||
# (backward compatibility).
|
||||
provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | openrouter | novita | fireworks | sglang | vllm | ollama
|
||||
provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | openrouter | novita | fireworks | moonshot | sglang | vllm | ollama | xiaomi
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
|
||||
base_url = "https://api.deepseek.com/beta"
|
||||
# provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com)
|
||||
@@ -191,6 +191,7 @@ max_subagents = 10 # optional (1-20)
|
||||
# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY
|
||||
# vLLM: VLLM_BASE_URL, VLLM_MODEL, optional VLLM_API_KEY
|
||||
# Ollama: OLLAMA_BASE_URL, OLLAMA_MODEL, optional OLLAMA_API_KEY
|
||||
# Xiaomi: MIMO_API_KEY, MIMO_BASE_URL, MIMO_MODEL
|
||||
|
||||
# DeepSeek Platform (https://platform.deepseek.com)
|
||||
[providers.deepseek]
|
||||
@@ -262,6 +263,12 @@ max_subagents = 10 # optional (1-20)
|
||||
# base_url = "http://localhost:11434/v1"
|
||||
# model = "deepseek-coder:1.3b" # or any local Ollama tag
|
||||
|
||||
# Xiaomi MiMo Token Plan (https://platform.xiaomimimo.com/docs/zh-CN/price/tokenplan/quick-access)
|
||||
[providers.xiaomi]
|
||||
# api_key = "tp-YOUR_MIMO_API_KEY" # Token Plan key (tp-xxxxx)
|
||||
# base_url = "https://token-plan-cn.xiaomimimo.com/v1" # cn / sgp / ams cluster
|
||||
# model = "mimo-v2.5-pro" # mimo-v2.5-pro | mimo-v2.5
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Web Search Provider
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -514,6 +521,19 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
|
||||
# # raise to a higher number for legitimate large
|
||||
# # monorepos.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Verification Gate (#2231)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# After every side-effect tool call (write_file, edit_file, apply_patch,
|
||||
# exec_shell), the engine runs a deterministic re-check before the result
|
||||
# enters the session message stream. If the re-check contradicts the claim,
|
||||
# the result is annotated with [VERIFY FAIL].
|
||||
|
||||
# [verification]
|
||||
# enabled = true # Enable the closed-loop verification gate
|
||||
# skip_tools = [] # Tools to skip verification for
|
||||
# max_retries = 2 # Max auto-retry attempts on verification failure
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# LSP Diagnostics (post-edit) (#136)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,5 +7,5 @@ repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
codewhale-config = { path = "../config", version = "0.8.46" }
|
||||
codewhale-config = { path = "../config", version = "0.8.47" }
|
||||
serde.workspace = true
|
||||
@@ -213,6 +213,20 @@ impl Default for ModelRegistry {
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mimo-v2.5-pro".to_string(),
|
||||
provider: ProviderKind::Xiaomi,
|
||||
aliases: vec!["mimo-pro".to_string(), "mimo-v2-pro".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mimo-v2.5".to_string(),
|
||||
provider: ProviderKind::Xiaomi,
|
||||
aliases: vec!["mimo-omni".to_string(), "mimo-v2-omni".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
];
|
||||
Self::new(models)
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.46" }
|
||||
codewhale-config = { path = "../config", version = "0.8.46" }
|
||||
codewhale-core = { path = "../core", version = "0.8.46" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.46" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.46" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.46" }
|
||||
codewhale-state = { path = "../state", version = "0.8.46" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.46" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.47" }
|
||||
codewhale-config = { path = "../config", version = "0.8.47" }
|
||||
codewhale-core = { path = "../core", version = "0.8.47" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.47" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.47" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.47" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.47" }
|
||||
codewhale-state = { path = "../state", version = "0.8.47" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.47" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -25,13 +25,13 @@ path = "src/bin/deepseek_legacy_shim.rs"
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.46" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.8.46" }
|
||||
codewhale-config = { path = "../config", version = "0.8.46" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.46" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.46" }
|
||||
codewhale-state = { path = "../state", version = "0.8.46" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.47" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.8.47" }
|
||||
codewhale-config = { path = "../config", version = "0.8.47" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.47" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.47" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.47" }
|
||||
codewhale-state = { path = "../state", version = "0.8.47" }
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -35,6 +35,7 @@ enum ProviderArg {
|
||||
Sglang,
|
||||
Vllm,
|
||||
Ollama,
|
||||
Xiaomi,
|
||||
}
|
||||
|
||||
impl From<ProviderArg> for ProviderKind {
|
||||
@@ -52,6 +53,7 @@ impl From<ProviderArg> for ProviderKind {
|
||||
ProviderArg::Sglang => ProviderKind::Sglang,
|
||||
ProviderArg::Vllm => ProviderKind::Vllm,
|
||||
ProviderArg::Ollama => ProviderKind::Ollama,
|
||||
ProviderArg::Xiaomi => ProviderKind::Xiaomi,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -724,11 +726,12 @@ fn provider_slot(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::Sglang => "sglang",
|
||||
ProviderKind::Vllm => "vllm",
|
||||
ProviderKind::Ollama => "ollama",
|
||||
ProviderKind::Xiaomi => "xiaomi",
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider order used by the `auth list` and `auth status` outputs.
|
||||
const PROVIDER_LIST: [ProviderKind; 12] = [
|
||||
const PROVIDER_LIST: [ProviderKind; 13] = [
|
||||
ProviderKind::Deepseek,
|
||||
ProviderKind::NvidiaNim,
|
||||
ProviderKind::Openai,
|
||||
@@ -741,6 +744,7 @@ const PROVIDER_LIST: [ProviderKind; 12] = [
|
||||
ProviderKind::Sglang,
|
||||
ProviderKind::Vllm,
|
||||
ProviderKind::Ollama,
|
||||
ProviderKind::Xiaomi,
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -796,6 +800,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
|
||||
ProviderKind::Sglang => &["SGLANG_API_KEY"],
|
||||
ProviderKind::Vllm => &["VLLM_API_KEY"],
|
||||
ProviderKind::Ollama => &["OLLAMA_API_KEY"],
|
||||
ProviderKind::Xiaomi => &["MIMO_API_KEY"],
|
||||
ProviderKind::Openai => &["OPENAI_API_KEY"],
|
||||
ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"],
|
||||
ProviderKind::WanjieArk => &[
|
||||
@@ -1479,9 +1484,10 @@ fn build_tui_command(
|
||||
| ProviderKind::Sglang
|
||||
| ProviderKind::Vllm
|
||||
| ProviderKind::Ollama
|
||||
| ProviderKind::Xiaomi
|
||||
) {
|
||||
bail!(
|
||||
"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.",
|
||||
"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, Ollama, and Xiaomi MiMo providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.",
|
||||
resolved_runtime.provider.as_str()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.46" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.47" }
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -45,6 +45,8 @@ const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
|
||||
const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
|
||||
const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
|
||||
const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
|
||||
const DEFAULT_XIAOMI_MODEL: &str = "mimo-v2.5-pro";
|
||||
const DEFAULT_XIAOMI_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -77,6 +79,8 @@ pub enum ProviderKind {
|
||||
Sglang,
|
||||
Vllm,
|
||||
Ollama,
|
||||
#[serde(alias = "mimo")]
|
||||
Xiaomi,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
@@ -95,6 +99,7 @@ impl ProviderKind {
|
||||
Self::Sglang => "sglang",
|
||||
Self::Vllm => "vllm",
|
||||
Self::Ollama => "ollama",
|
||||
Self::Xiaomi => "xiaomi",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +120,7 @@ impl ProviderKind {
|
||||
"sglang" | "sg-lang" => Some(Self::Sglang),
|
||||
"vllm" | "v-llm" => Some(Self::Vllm),
|
||||
"ollama" | "ollama-local" => Some(Self::Ollama),
|
||||
"xiaomi" | "mimo" | "xiaomi-mimo" | "xiaomi_mimo" => Some(Self::Xiaomi),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -156,6 +162,8 @@ pub struct ProvidersToml {
|
||||
pub vllm: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub ollama: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub xiaomi: ProviderConfigToml,
|
||||
}
|
||||
|
||||
impl ProvidersToml {
|
||||
@@ -174,6 +182,7 @@ impl ProvidersToml {
|
||||
ProviderKind::Sglang => &self.sglang,
|
||||
ProviderKind::Vllm => &self.vllm,
|
||||
ProviderKind::Ollama => &self.ollama,
|
||||
ProviderKind::Xiaomi => &self.xiaomi,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +200,7 @@ impl ProvidersToml {
|
||||
ProviderKind::Sglang => &mut self.sglang,
|
||||
ProviderKind::Vllm => &mut self.vllm,
|
||||
ProviderKind::Ollama => &mut self.ollama,
|
||||
ProviderKind::Xiaomi => &mut self.xiaomi,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,6 +420,7 @@ impl ConfigToml {
|
||||
merge_project_provider_config(&mut self.providers.sglang, &project.providers.sglang);
|
||||
merge_project_provider_config(&mut self.providers.vllm, &project.providers.vllm);
|
||||
merge_project_provider_config(&mut self.providers.ollama, &project.providers.ollama);
|
||||
merge_project_provider_config(&mut self.providers.xiaomi, &project.providers.xiaomi);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -501,6 +512,12 @@ impl ConfigToml {
|
||||
"providers.ollama.http_headers" => {
|
||||
serialize_http_headers(&self.providers.ollama.http_headers)
|
||||
}
|
||||
"providers.xiaomi.api_key" => self.providers.xiaomi.api_key.clone(),
|
||||
"providers.xiaomi.base_url" => self.providers.xiaomi.base_url.clone(),
|
||||
"providers.xiaomi.model" => self.providers.xiaomi.model.clone(),
|
||||
"providers.xiaomi.http_headers" => {
|
||||
serialize_http_headers(&self.providers.xiaomi.http_headers)
|
||||
}
|
||||
_ => self.extras.get(key).map(toml::Value::to_string),
|
||||
}
|
||||
}
|
||||
@@ -684,6 +701,18 @@ impl ConfigToml {
|
||||
"providers.ollama.http_headers" => {
|
||||
self.providers.ollama.http_headers = parse_http_headers(value)?;
|
||||
}
|
||||
"providers.xiaomi.api_key" => {
|
||||
self.providers.xiaomi.api_key = Some(value.to_string());
|
||||
}
|
||||
"providers.xiaomi.base_url" => {
|
||||
self.providers.xiaomi.base_url = Some(value.to_string());
|
||||
}
|
||||
"providers.xiaomi.model" => {
|
||||
self.providers.xiaomi.model = Some(value.to_string());
|
||||
}
|
||||
"providers.xiaomi.http_headers" => {
|
||||
self.providers.xiaomi.http_headers = parse_http_headers(value)?;
|
||||
}
|
||||
_ => {
|
||||
self.extras
|
||||
.insert(key.to_string(), toml::Value::String(value.to_string()));
|
||||
@@ -769,6 +798,10 @@ impl ConfigToml {
|
||||
"providers.ollama.base_url" => self.providers.ollama.base_url = None,
|
||||
"providers.ollama.model" => self.providers.ollama.model = None,
|
||||
"providers.ollama.http_headers" => self.providers.ollama.http_headers.clear(),
|
||||
"providers.xiaomi.api_key" => self.providers.xiaomi.api_key = None,
|
||||
"providers.xiaomi.base_url" => self.providers.xiaomi.base_url = None,
|
||||
"providers.xiaomi.model" => self.providers.xiaomi.model = None,
|
||||
"providers.xiaomi.http_headers" => self.providers.xiaomi.http_headers.clear(),
|
||||
_ => {
|
||||
self.extras.remove(key);
|
||||
}
|
||||
@@ -961,6 +994,18 @@ impl ConfigToml {
|
||||
if let Some(v) = serialize_http_headers(&self.providers.ollama.http_headers) {
|
||||
out.insert("providers.ollama.http_headers".to_string(), v);
|
||||
}
|
||||
if let Some(v) = self.providers.xiaomi.api_key.as_ref() {
|
||||
out.insert("providers.xiaomi.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.xiaomi.base_url.as_ref() {
|
||||
out.insert("providers.xiaomi.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.xiaomi.model.as_ref() {
|
||||
out.insert("providers.xiaomi.model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = serialize_http_headers(&self.providers.xiaomi.http_headers) {
|
||||
out.insert("providers.xiaomi.http_headers".to_string(), v);
|
||||
}
|
||||
|
||||
for (k, v) in &self.extras {
|
||||
out.insert(k.clone(), v.to_string());
|
||||
@@ -1035,6 +1080,7 @@ impl ConfigToml {
|
||||
ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
|
||||
ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
|
||||
ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
|
||||
ProviderKind::Xiaomi => DEFAULT_XIAOMI_BASE_URL.to_string(),
|
||||
});
|
||||
// CLI flag wins outright. Otherwise: config-file → injected secrets/env.
|
||||
// This makes `deepseek auth set` a reliable fix even when the user's
|
||||
@@ -1225,7 +1271,10 @@ pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
|
||||
fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
|
||||
if matches!(
|
||||
provider,
|
||||
ProviderKind::Atlascloud | ProviderKind::WanjieArk | ProviderKind::Ollama
|
||||
ProviderKind::Atlascloud
|
||||
| ProviderKind::WanjieArk
|
||||
| ProviderKind::Ollama
|
||||
| ProviderKind::Xiaomi
|
||||
) {
|
||||
return model.to_string();
|
||||
}
|
||||
@@ -1294,6 +1343,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
|
||||
ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
|
||||
ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
|
||||
ProviderKind::Xiaomi => DEFAULT_XIAOMI_MODEL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1311,6 +1361,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
|
||||
ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
|
||||
ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
|
||||
ProviderKind::Xiaomi => DEFAULT_XIAOMI_BASE_URL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1820,6 +1871,7 @@ struct EnvRuntimeOverrides {
|
||||
sglang_base_url: Option<String>,
|
||||
vllm_base_url: Option<String>,
|
||||
ollama_base_url: Option<String>,
|
||||
xiaomi_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvRuntimeOverrides {
|
||||
@@ -1901,6 +1953,9 @@ impl EnvRuntimeOverrides {
|
||||
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_base_url: std::env::var("MIMO_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1920,6 +1975,7 @@ impl EnvRuntimeOverrides {
|
||||
ProviderKind::Sglang => self.sglang_base_url.clone(),
|
||||
ProviderKind::Vllm => self.vllm_base_url.clone(),
|
||||
ProviderKind::Ollama => self.ollama_base_url.clone(),
|
||||
ProviderKind::Xiaomi => self.xiaomi_base_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1999,6 +2055,8 @@ mod tests {
|
||||
vllm_base_url: Option<OsString>,
|
||||
ollama_api_key: Option<OsString>,
|
||||
ollama_base_url: Option<OsString>,
|
||||
mimo_api_key: Option<OsString>,
|
||||
mimo_base_url: Option<OsString>,
|
||||
codewhale_provider: Option<OsString>,
|
||||
codewhale_model: Option<OsString>,
|
||||
codewhale_base_url: Option<OsString>,
|
||||
@@ -2048,6 +2106,8 @@ mod tests {
|
||||
vllm_base_url: env::var_os("VLLM_BASE_URL"),
|
||||
ollama_api_key: env::var_os("OLLAMA_API_KEY"),
|
||||
ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
|
||||
mimo_api_key: env::var_os("MIMO_API_KEY"),
|
||||
mimo_base_url: env::var_os("MIMO_BASE_URL"),
|
||||
};
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
@@ -2092,6 +2152,8 @@ mod tests {
|
||||
env::remove_var("VLLM_BASE_URL");
|
||||
env::remove_var("OLLAMA_API_KEY");
|
||||
env::remove_var("OLLAMA_BASE_URL");
|
||||
env::remove_var("MIMO_API_KEY");
|
||||
env::remove_var("MIMO_BASE_URL");
|
||||
}
|
||||
guard
|
||||
}
|
||||
@@ -2153,6 +2215,8 @@ mod tests {
|
||||
Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
|
||||
Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
|
||||
Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
|
||||
Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
|
||||
Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.46" }
|
||||
codewhale-config = { path = "../config", version = "0.8.46" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.46" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.46" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.46" }
|
||||
codewhale-state = { path = "../state", version = "0.8.46" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.46" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.47" }
|
||||
codewhale-config = { path = "../config", version = "0.8.47" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.47" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.47" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.47" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.47" }
|
||||
codewhale-state = { path = "../state", version = "0.8.47" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.47" }
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.46" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.47" }
|
||||
serde.workspace = true
|
||||
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.46" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.47" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -537,6 +537,7 @@ pub fn env_for(name: &str) -> Option<String> {
|
||||
"sglang" | "sg-lang" => &["SGLANG_API_KEY"],
|
||||
"vllm" | "v-llm" => &["VLLM_API_KEY"],
|
||||
"ollama" | "ollama-local" => &["OLLAMA_API_KEY"],
|
||||
"xiaomi" | "mimo" | "xiaomi-mimo" => &["MIMO_API_KEY"],
|
||||
"openai" => &["OPENAI_API_KEY"],
|
||||
"atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"],
|
||||
"wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.46" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.47" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../CHANGELOG.md
|
||||
@@ -27,9 +27,9 @@ path = "src/bin/deepseek_tui_legacy_shim.rs"
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
codewhale-config = { path = "../config", version = "0.8.46" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.46" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.46" }
|
||||
codewhale-config = { path = "../config", version = "0.8.47" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.47" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.47" }
|
||||
schemaui = { version = "0.12.0", default-features = false, optional = true }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -207,6 +207,7 @@ impl AcpServer {
|
||||
stream: Some(false),
|
||||
temperature: Some(0.2),
|
||||
top_p: Some(0.9),
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = client.create_message(request).await?;
|
||||
|
||||
@@ -901,6 +901,10 @@ pub(super) fn apply_reasoning_effort(
|
||||
"enable_thinking": false,
|
||||
});
|
||||
}
|
||||
// MiMo uses thinking: { type: "disabled" } to turn off reasoning
|
||||
ApiProvider::Xiaomi => {
|
||||
body["thinking"] = json!({ "type": "disabled" });
|
||||
}
|
||||
ApiProvider::Openai
|
||||
| ApiProvider::Atlascloud
|
||||
| ApiProvider::WanjieArk
|
||||
@@ -918,6 +922,10 @@ pub(super) fn apply_reasoning_effort(
|
||||
body["reasoning_effort"] = json!("high");
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
}
|
||||
// MiMo uses the same thinking format as DeepSeek
|
||||
ApiProvider::Xiaomi => {
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
}
|
||||
// OpenRouter/Novita: pass through the actual user-chosen value.
|
||||
// OpenRouter's unified scale is none/minimal/low/medium/high/xhigh;
|
||||
// DeepSeek models hosted there accept those directly.
|
||||
@@ -963,6 +971,10 @@ pub(super) fn apply_reasoning_effort(
|
||||
body["reasoning_effort"] = json!("max");
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
}
|
||||
// MiMo doesn't have max/low distinction — just enable thinking
|
||||
ApiProvider::Xiaomi => {
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
}
|
||||
ApiProvider::Openrouter | ApiProvider::Novita => {
|
||||
body["reasoning_effort"] = json!("xhigh");
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
@@ -1338,6 +1350,7 @@ mod tests {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let openai = build_chat_messages_for_request_and_provider(&request, ApiProvider::Openai);
|
||||
@@ -1616,6 +1629,7 @@ mod tests {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let out = build_chat_messages_for_request(&request);
|
||||
@@ -1672,6 +1686,7 @@ mod tests {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let out = build_chat_messages_for_request(&request);
|
||||
@@ -1724,6 +1739,7 @@ mod tests {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let inspection = inspect_prompt_for_request(&request);
|
||||
@@ -1786,6 +1802,7 @@ mod tests {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1848,6 +1865,7 @@ mod tests {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let first = inspect_prompt_for_request(&request);
|
||||
@@ -1903,6 +1921,7 @@ mod tests {
|
||||
stream: Some(true),
|
||||
temperature: Some(0.7),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let warmup = build_cache_warmup_request(&request);
|
||||
|
||||
@@ -182,6 +182,10 @@ impl DeepSeekClient {
|
||||
self.api_provider,
|
||||
);
|
||||
|
||||
if let Some(ref response_format) = request.response_format {
|
||||
body["response_format"] = response_format.clone();
|
||||
}
|
||||
|
||||
// Bulletproof final sanitizer: walk the wire payload and force
|
||||
// `reasoning_content` onto any assistant message that has tool_calls
|
||||
// but no reasoning_content. DeepSeek's thinking-mode API rejects
|
||||
@@ -519,6 +523,7 @@ impl<'a> PromptBuilder<'a> {
|
||||
stream: None,
|
||||
temperature: Some(0.0),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1667,6 +1672,9 @@ fn requires_reasoning_content(model: &str) -> bool {
|
||||
|| lower.contains("-reasoning")
|
||||
|| lower.contains("-thinking")
|
||||
|| has_deepseek_r_series_marker(&lower)
|
||||
// MiMo models require reasoning_content replay in multi-turn tool calls
|
||||
// per the official Xiaomi MiMo API documentation.
|
||||
|| lower.starts_with("mimo")
|
||||
}
|
||||
|
||||
fn should_replay_reasoning_content(model: &str, effort: Option<&str>) -> bool {
|
||||
@@ -1732,6 +1740,7 @@ fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool {
|
||||
| ApiProvider::Novita
|
||||
| ApiProvider::Fireworks
|
||||
| ApiProvider::Sglang
|
||||
| ApiProvider::Xiaomi
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2763,6 +2772,7 @@ mod stream_decoder_tests {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let inspection = inspect_prompt_for_request(&request);
|
||||
@@ -3050,6 +3060,7 @@ mod stream_decoder_tests {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let inspection = inspect_prompt_for_request(&request);
|
||||
|
||||
@@ -473,9 +473,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
|
||||
}
|
||||
}
|
||||
return CommandResult::error(format!(
|
||||
"base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving."
|
||||
));
|
||||
return CommandResult::error("base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1179,6 +1177,7 @@ async fn auto_route_flash_recommendation(
|
||||
stream: Some(false),
|
||||
temperature: Some(0.0),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response =
|
||||
|
||||
@@ -180,6 +180,7 @@ fn format_cache_inspect(app: &mut App) -> String {
|
||||
stream: Some(true),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
let inspection = inspect_prompt_for_request(&request);
|
||||
let previous = app.session.last_cache_inspection.as_ref();
|
||||
|
||||
@@ -1352,6 +1352,7 @@ fn build_cache_aligned_summary_request(
|
||||
stream: Some(false),
|
||||
temperature: Some(0.3),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1426,6 +1427,7 @@ fn build_formatted_summary_request(
|
||||
stream: Some(false),
|
||||
temperature: Some(0.3),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ pub const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
|
||||
pub const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
|
||||
pub const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
|
||||
pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
|
||||
pub const DEFAULT_XIAOMI_MODEL: &str = "mimo-v2.5-pro";
|
||||
pub const DEFAULT_XIAOMI_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1";
|
||||
/// Legacy `deepseek-cn` provider alias.
|
||||
///
|
||||
/// DeepSeek's official API host is the same worldwide. Keep this alias for
|
||||
@@ -97,6 +99,7 @@ pub enum ApiProvider {
|
||||
Sglang,
|
||||
Vllm,
|
||||
Ollama,
|
||||
Xiaomi,
|
||||
}
|
||||
|
||||
impl ApiProvider {
|
||||
@@ -119,6 +122,7 @@ impl ApiProvider {
|
||||
"sglang" | "sg-lang" => Some(Self::Sglang),
|
||||
"vllm" | "v-llm" => Some(Self::Vllm),
|
||||
"ollama" | "ollama-local" => Some(Self::Ollama),
|
||||
"xiaomi" | "mimo" | "xiaomi-mimo" | "xiaomi_mimo" => Some(Self::Xiaomi),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -139,6 +143,7 @@ impl ApiProvider {
|
||||
Self::Sglang => "sglang",
|
||||
Self::Vllm => "vllm",
|
||||
Self::Ollama => "ollama",
|
||||
Self::Xiaomi => "xiaomi",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +164,7 @@ impl ApiProvider {
|
||||
Self::Sglang => "SGLang",
|
||||
Self::Vllm => "vLLM",
|
||||
Self::Ollama => "Ollama",
|
||||
Self::Xiaomi => "Xiaomi MiMo",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +184,7 @@ impl ApiProvider {
|
||||
Self::Sglang,
|
||||
Self::Vllm,
|
||||
Self::Ollama,
|
||||
Self::Xiaomi,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -272,6 +279,27 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi
|
||||
};
|
||||
}
|
||||
|
||||
if matches!(provider, ApiProvider::Xiaomi) {
|
||||
let model_lower = resolved_model.to_ascii_lowercase();
|
||||
let is_flash = model_lower.contains("flash");
|
||||
let (context_window, max_output) = if is_flash {
|
||||
(256_000, 64_000)
|
||||
} else {
|
||||
// mimo-v2.5-pro and mimo-v2.5 (omni) both have 1M context
|
||||
(1_000_000, 128_000)
|
||||
};
|
||||
return ProviderCapability {
|
||||
provider,
|
||||
resolved_model: resolved_model.to_string(),
|
||||
context_window,
|
||||
max_output,
|
||||
thinking_supported: true,
|
||||
cache_telemetry_supported: false,
|
||||
request_payload_mode: RequestPayloadMode::ChatCompletions,
|
||||
alias_deprecation: None,
|
||||
};
|
||||
}
|
||||
|
||||
let model_lower = resolved_model.to_ascii_lowercase();
|
||||
let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
|
||||
deepseek_alias_deprecation(&model_lower)
|
||||
@@ -417,6 +445,18 @@ fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {
|
||||
/// aliases like `deepseek-v4pro` → `deepseek-v4-pro`.
|
||||
#[must_use]
|
||||
pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
|
||||
let trimmed = model.trim();
|
||||
// Xiaomi MiMo models bypass DeepSeek-only normalization — accept any
|
||||
// valid model name including "auto" and custom/fine-tuned models.
|
||||
if matches!(provider, ApiProvider::Xiaomi) {
|
||||
if trimmed
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':'))
|
||||
{
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let normalized = normalize_model_name(model)?;
|
||||
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
|
||||
&& let Some(canonical) = canonical_official_deepseek_model_id(&normalized)
|
||||
@@ -452,6 +492,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati
|
||||
ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {
|
||||
OFFICIAL_DEEPSEEK_MODELS.to_vec()
|
||||
}
|
||||
ApiProvider::Xiaomi => vec![DEFAULT_XIAOMI_MODEL, "mimo-v2.5"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1352,6 +1393,8 @@ pub struct ProvidersConfig {
|
||||
pub vllm: ProviderConfig,
|
||||
#[serde(default)]
|
||||
pub ollama: ProviderConfig,
|
||||
#[serde(default)]
|
||||
pub xiaomi: ProviderConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
@@ -1506,6 +1549,7 @@ impl Config {
|
||||
ApiProvider::Sglang => "providers.sglang",
|
||||
ApiProvider::Vllm => "providers.vllm",
|
||||
ApiProvider::Ollama => "providers.ollama",
|
||||
ApiProvider::Xiaomi => "providers.xiaomi",
|
||||
ApiProvider::NvidiaNim => "providers.nvidia_nim",
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,
|
||||
};
|
||||
@@ -1648,6 +1692,7 @@ impl Config {
|
||||
ApiProvider::Sglang => &providers.sglang,
|
||||
ApiProvider::Vllm => &providers.vllm,
|
||||
ApiProvider::Ollama => &providers.ollama,
|
||||
ApiProvider::Xiaomi => &providers.xiaomi,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1738,6 +1783,7 @@ impl Config {
|
||||
ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,
|
||||
ApiProvider::Vllm => DEFAULT_VLLM_MODEL,
|
||||
ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,
|
||||
ApiProvider::Xiaomi => DEFAULT_XIAOMI_MODEL,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
@@ -1769,7 +1815,8 @@ impl Config {
|
||||
| ApiProvider::Moonshot
|
||||
| ApiProvider::Sglang
|
||||
| ApiProvider::Vllm
|
||||
| ApiProvider::Ollama => None,
|
||||
| ApiProvider::Ollama
|
||||
| ApiProvider::Xiaomi => None,
|
||||
};
|
||||
let base = provider_base.or(root_base).unwrap_or_else(|| {
|
||||
match provider {
|
||||
@@ -1795,6 +1842,7 @@ impl Config {
|
||||
ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,
|
||||
ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,
|
||||
ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,
|
||||
ApiProvider::Xiaomi => DEFAULT_XIAOMI_BASE_URL,
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
@@ -1829,6 +1877,7 @@ impl Config {
|
||||
ApiProvider::Sglang => "sglang",
|
||||
ApiProvider::Vllm => "vllm",
|
||||
ApiProvider::Ollama => "ollama",
|
||||
ApiProvider::Xiaomi => "xiaomi",
|
||||
};
|
||||
|
||||
// 0. DeepSeek compatibility slot. The legacy top-level `api_key`
|
||||
@@ -1922,6 +1971,10 @@ impl Config {
|
||||
For a Kimi Code plan key, set [providers.moonshot] base_url = \
|
||||
\"https://api.kimi.com/coding/v1\" and model = \"kimi-for-coding\"."
|
||||
),
|
||||
ApiProvider::Xiaomi => anyhow::bail!(
|
||||
"Xiaomi MiMo API key not found. Run 'codewhale auth set --provider xiaomi', \
|
||||
set MIMO_API_KEY, or add [providers.xiaomi] api_key in ~/.deepseek/config.toml."
|
||||
),
|
||||
// Self-hosted deployments commonly run without auth on localhost.
|
||||
// Return an empty key and let the client omit the Authorization header.
|
||||
ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),
|
||||
@@ -2545,6 +2598,13 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
.ollama
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::Xiaomi => {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.xiaomi
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::Atlascloud => {
|
||||
config
|
||||
.providers
|
||||
@@ -2687,6 +2747,7 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
ApiProvider::Sglang => &mut providers.sglang,
|
||||
ApiProvider::Vllm => &mut providers.vllm,
|
||||
ApiProvider::Ollama => &mut providers.ollama,
|
||||
ApiProvider::Xiaomi => &mut providers.xiaomi,
|
||||
};
|
||||
let mut provider_headers = entry.http_headers.clone().unwrap_or_default();
|
||||
provider_headers.extend(headers);
|
||||
@@ -2717,6 +2778,21 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
{
|
||||
config.default_text_model = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::Xiaomi)
|
||||
&& let Ok(value) = std::env::var("MIMO_BASE_URL")
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.xiaomi
|
||||
.base_url = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::Xiaomi)
|
||||
&& let Ok(value) = std::env::var("MIMO_MODEL")
|
||||
{
|
||||
config.default_text_model = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::Openai)
|
||||
&& let Ok(value) = std::env::var("OPENAI_MODEL")
|
||||
{
|
||||
@@ -2791,6 +2867,7 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
ApiProvider::Sglang => &mut providers.sglang,
|
||||
ApiProvider::Vllm => &mut providers.vllm,
|
||||
ApiProvider::Ollama => &mut providers.ollama,
|
||||
ApiProvider::Xiaomi => &mut providers.xiaomi,
|
||||
};
|
||||
entry.model = Some(value);
|
||||
}
|
||||
@@ -3076,6 +3153,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str {
|
||||
ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,
|
||||
ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,
|
||||
ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,
|
||||
ApiProvider::Xiaomi => DEFAULT_XIAOMI_BASE_URL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3333,6 +3411,7 @@ fn merge_providers(
|
||||
sglang: merge_provider_config(base.sglang, override_cfg.sglang),
|
||||
vllm: merge_provider_config(base.vllm, override_cfg.vllm),
|
||||
ollama: merge_provider_config(base.ollama, override_cfg.ollama),
|
||||
xiaomi: merge_provider_config(base.xiaomi, override_cfg.xiaomi),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -3758,6 +3837,7 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool {
|
||||
ApiProvider::Sglang => std::env::var("SGLANG_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
|
||||
ApiProvider::Vllm => std::env::var("VLLM_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
|
||||
ApiProvider::Ollama => std::env::var("OLLAMA_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
|
||||
ApiProvider::Xiaomi => std::env::var("MIMO_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3784,6 +3864,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
|
||||
ApiProvider::Sglang => "SGLANG_API_KEY",
|
||||
ApiProvider::Vllm => "VLLM_API_KEY",
|
||||
ApiProvider::Ollama => "OLLAMA_API_KEY",
|
||||
ApiProvider::Xiaomi => "MIMO_API_KEY",
|
||||
};
|
||||
if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {
|
||||
return true;
|
||||
@@ -3878,6 +3959,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
|
||||
ApiProvider::Sglang => "providers.sglang",
|
||||
ApiProvider::Vllm => "providers.vllm",
|
||||
ApiProvider::Ollama => "providers.ollama",
|
||||
ApiProvider::Xiaomi => "providers.xiaomi",
|
||||
};
|
||||
|
||||
// Parse existing TOML (or start fresh) so we can edit the right table
|
||||
@@ -3915,6 +3997,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
|
||||
ApiProvider::Sglang => "sglang",
|
||||
ApiProvider::Vllm => "vllm",
|
||||
ApiProvider::Ollama => "ollama",
|
||||
ApiProvider::Xiaomi => "xiaomi",
|
||||
};
|
||||
let entry = providers
|
||||
.entry(key_inside.to_string())
|
||||
@@ -4004,6 +4087,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> {
|
||||
ApiProvider::Sglang => Ok("sglang"),
|
||||
ApiProvider::Vllm => Ok("vllm"),
|
||||
ApiProvider::Ollama => Ok("ollama"),
|
||||
ApiProvider::Xiaomi => Ok("xiaomi"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use futures_util::StreamExt;
|
||||
use futures_util::stream::FuturesUnordered;
|
||||
use serde_json::json;
|
||||
@@ -173,6 +174,15 @@ pub struct EngineConfig {
|
||||
/// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo.
|
||||
/// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key.
|
||||
pub search_api_key: Option<String>,
|
||||
/// Enable the closed-loop verification gate. When true, the engine
|
||||
/// re-checks side-effect tool claims (write_file, edit_file, apply_patch,
|
||||
/// exec_shell) before accepting them as fact.
|
||||
/// Default: true in YOLO mode, false otherwise.
|
||||
pub verification_enabled: bool,
|
||||
/// Max auto-retry attempts when the verification gate fails.
|
||||
/// File-mutating tools (write_file, edit_file, apply_patch) are
|
||||
/// re-executed on [VERIFY FAIL] up to this many times. Default: 2.
|
||||
pub verification_max_retries: u32,
|
||||
/// Per-step DeepSeek API timeout for sub-agent `create_message` requests.
|
||||
/// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=1800)
|
||||
/// once at engine construction, then threaded onto every
|
||||
@@ -232,6 +242,8 @@ impl Default for EngineConfig {
|
||||
),
|
||||
tools_always_load: HashSet::new(),
|
||||
prefer_bwrap: false,
|
||||
verification_enabled: true,
|
||||
verification_max_retries: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,6 +349,10 @@ pub struct Engine {
|
||||
/// — when LSP is disabled in config, this is an inert manager that
|
||||
/// always returns `None` from `diagnostics_for`.
|
||||
lsp_manager: Arc<crate::lsp::LspManager>,
|
||||
/// Closed-loop verification gate. Re-checks tool claims before
|
||||
/// the result enters the session message stream.
|
||||
#[allow(dead_code)]
|
||||
verify_config: verify::VerifyConfig,
|
||||
/// Session-scoped workshop variable store (#548). Shared across all tool
|
||||
/// calls so `last_tool_result` persists within the session and can be
|
||||
/// promoted to the parent context via `promote_to_context`.
|
||||
@@ -393,6 +409,7 @@ impl Engine {
|
||||
ApiProvider::Sglang => "SGLANG_API_KEY",
|
||||
ApiProvider::Vllm => "VLLM_API_KEY",
|
||||
ApiProvider::Ollama => "OLLAMA_API_KEY",
|
||||
ApiProvider::Xiaomi => "MIMO_API_KEY",
|
||||
};
|
||||
|
||||
Some(format!(
|
||||
@@ -588,6 +605,7 @@ impl Engine {
|
||||
coherence_state: CoherenceState::default(),
|
||||
turn_counter: 0,
|
||||
lsp_manager,
|
||||
verify_config: verify::VerifyConfig::default(),
|
||||
pending_lsp_blocks: Vec::new(),
|
||||
workshop_vars,
|
||||
sandbox_backend,
|
||||
@@ -2084,6 +2102,7 @@ mod tool_catalog;
|
||||
mod tool_execution;
|
||||
mod tool_setup;
|
||||
mod turn_loop;
|
||||
pub(crate) mod verify;
|
||||
|
||||
use self::approval::{ApprovalDecision, ApprovalResult, UserInputDecision};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -424,7 +424,9 @@ fn core_native_tools_stay_loaded_in_yolo_mode() {
|
||||
AppMode::Yolo,
|
||||
&always_load
|
||||
));
|
||||
assert!(should_default_defer_tool(
|
||||
// All native tools are loaded upfront; previously-deferred tools
|
||||
// like git_show are now also non-deferred.
|
||||
assert!(!should_default_defer_tool(
|
||||
"git_show",
|
||||
AppMode::Yolo,
|
||||
&always_load
|
||||
@@ -464,7 +466,9 @@ fn non_yolo_mode_retains_default_defer_policy() {
|
||||
AppMode::Agent,
|
||||
&always_load
|
||||
));
|
||||
assert!(should_default_defer_tool(
|
||||
// With the v0.8.47 "all native tools loaded" policy, every tool
|
||||
// (including previously-deferred ones like git_show) is non-deferred.
|
||||
assert!(!should_default_defer_tool(
|
||||
"git_show",
|
||||
AppMode::Agent,
|
||||
&always_load
|
||||
@@ -472,7 +476,7 @@ fn non_yolo_mode_retains_default_defer_policy() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_tool_catalog_applies_native_and_mcp_deferral() {
|
||||
fn model_tool_catalog_keeps_all_native_tools_loaded() {
|
||||
let always_load = HashSet::new();
|
||||
let catalog = build_model_tool_catalog(
|
||||
vec![
|
||||
@@ -498,7 +502,7 @@ fn model_tool_catalog_applies_native_and_mcp_deferral() {
|
||||
assert_eq!(defer_loading("write_file"), Some(false));
|
||||
assert_eq!(defer_loading("exec_shell"), Some(false));
|
||||
assert_eq!(defer_loading("edit_file"), Some(false));
|
||||
assert_eq!(defer_loading("project_map"), Some(true));
|
||||
assert_eq!(defer_loading("project_map"), Some(false));
|
||||
assert_eq!(defer_loading("list_mcp_resources"), Some(false));
|
||||
assert_eq!(defer_loading("mcp_server_write"), Some(true));
|
||||
}
|
||||
@@ -717,7 +721,7 @@ fn deferred_edit_file_first_use_hydrates_schema_without_execution() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_tool_catalog_defers_non_core_native_tools_in_yolo_mode() {
|
||||
fn model_tool_catalog_keeps_all_native_tools_loaded_in_yolo_mode() {
|
||||
let always_load = HashSet::new();
|
||||
let catalog = build_model_tool_catalog(
|
||||
vec![api_tool("read_file"), api_tool("project_map")],
|
||||
@@ -734,7 +738,7 @@ fn model_tool_catalog_defers_non_core_native_tools_in_yolo_mode() {
|
||||
};
|
||||
|
||||
assert_eq!(defer_loading("read_file"), Some(false));
|
||||
assert_eq!(defer_loading("project_map"), Some(true));
|
||||
assert_eq!(defer_loading("project_map"), Some(false));
|
||||
assert_eq!(defer_loading("mcp_server_write"), Some(false));
|
||||
}
|
||||
|
||||
@@ -860,12 +864,17 @@ fn deferred_tool_preflight_guides_checklist_update_list_replacement() {
|
||||
)
|
||||
.build(engine.build_tool_context(AppMode::Agent, false));
|
||||
let always_load = HashSet::new();
|
||||
let catalog = build_model_tool_catalog(
|
||||
let mut catalog = build_model_tool_catalog(
|
||||
registry.to_api_tools_with_cache(true),
|
||||
vec![],
|
||||
AppMode::Agent,
|
||||
&always_load,
|
||||
);
|
||||
// Manually defer checklist_update to test the preflight/hydration
|
||||
// mechanism — the default policy now keeps all native tools loaded.
|
||||
if let Some(tool) = catalog.iter_mut().find(|t| t.name == "checklist_update") {
|
||||
tool.defer_loading = Some(true);
|
||||
}
|
||||
let mut active = initial_active_tools(&catalog);
|
||||
assert!(!active.contains("checklist_update"));
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ pub(super) fn is_tool_search_tool(name: &str) -> bool {
|
||||
matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME)
|
||||
}
|
||||
|
||||
// Kept as documentation of the original core tool set. No longer drives
|
||||
// deferral policy since all native tools are loaded upfront (v0.8.47).
|
||||
#[allow(dead_code)]
|
||||
pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
|
||||
"agent_open",
|
||||
"apply_patch",
|
||||
@@ -52,21 +55,16 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
|
||||
];
|
||||
|
||||
pub(super) fn should_default_defer_tool(
|
||||
name: &str,
|
||||
_name: &str,
|
||||
_mode: AppMode,
|
||||
always_load: &HashSet<String>,
|
||||
_always_load: &HashSet<String>,
|
||||
) -> bool {
|
||||
if always_load.contains(name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_tool_search_tool(name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!DEFAULT_ACTIVE_NATIVE_TOOLS
|
||||
.iter()
|
||||
.any(|core_tool| core_tool == &name)
|
||||
// All native tools are loaded upfront — no deferred loading.
|
||||
// The tool_search discovery tools remain registered so the model
|
||||
// can still search the catalog (useful for MCP tools and tool
|
||||
// discovery in general), but native tools no longer require a
|
||||
// search step to become available.
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn apply_native_tool_deferral(
|
||||
|
||||
@@ -359,6 +359,35 @@ impl Engine {
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Auto-retry a tool that failed verification. For file-mutating tools
|
||||
/// (write_file, edit_file, apply_patch), re-executes with the same input.
|
||||
/// Called from the turn loop when the verification gate detects a [VERIFY FAIL].
|
||||
pub(super) async fn retry_file_tool(
|
||||
lock: Arc<RwLock<()>>,
|
||||
tool_name: &str,
|
||||
tool_input: serde_json::Value,
|
||||
tx_event: mpsc::Sender<Event>,
|
||||
registry: Option<&crate::tools::ToolRegistry>,
|
||||
mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
|
||||
) -> Result<(ToolResult, bool), ToolError> {
|
||||
let result = Self::execute_tool_with_lock(
|
||||
lock,
|
||||
false, // retries run sequentially, not parallel
|
||||
false, // file tools are not interactive
|
||||
tx_event,
|
||||
tool_name.to_string(),
|
||||
tool_input,
|
||||
registry,
|
||||
mcp_pool,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Check if the retry succeeded: non-empty file on disk
|
||||
let success = result.success;
|
||||
Ok((result, success))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -302,6 +302,7 @@ impl Engine {
|
||||
stream: Some(true),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
// Stream the response. Keep the request around (cloned into the
|
||||
@@ -1865,7 +1866,7 @@ impl Engine {
|
||||
"tool_name": outcome.name.clone(),
|
||||
"success": output.success,
|
||||
}));
|
||||
let output_for_context = compact_tool_result_for_context(
|
||||
let mut output_for_context = compact_tool_result_for_context(
|
||||
&self.session.model,
|
||||
&outcome.name,
|
||||
&output,
|
||||
@@ -1877,6 +1878,7 @@ impl Engine {
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let output_content = output.content;
|
||||
let output_success = output.success;
|
||||
|
||||
tool_call.set_result(output_content.clone(), duration);
|
||||
self.session.working_set.observe_tool_call(
|
||||
@@ -1890,45 +1892,354 @@ impl Engine {
|
||||
// this on success — failed edits leave the file
|
||||
// untouched, so polling for diagnostics would just
|
||||
// surface stale state.
|
||||
if output.success && tool_was_executed {
|
||||
if output_success && tool_was_executed {
|
||||
self.run_post_edit_lsp_hook(&outcome.name, &tool_input)
|
||||
.await;
|
||||
}
|
||||
|
||||
// CLOSED-LOOP: verification gate with auto-retry.
|
||||
// Re-checks side-effect claims before the result enters
|
||||
// the session stream. File-mutating tools are retried
|
||||
// automatically on failure (up to max_retries).
|
||||
let mut verify_verdict = verify::VerifyVerdict::Skipped;
|
||||
let mut verify_annotation = String::new();
|
||||
let mut retry_count: u32 = 0;
|
||||
|
||||
if self.config.verification_enabled && output_success && tool_was_executed {
|
||||
let max_retries = self.config.verification_max_retries;
|
||||
let is_retryable = verify::is_auto_retryable(&outcome.name);
|
||||
|
||||
loop {
|
||||
let (verdict, annotation) = verify::run_verification(
|
||||
&outcome.name,
|
||||
&tool_input,
|
||||
&self.session.workspace,
|
||||
);
|
||||
|
||||
// If passed, skipped, or unverifiable — done.
|
||||
let should_retry =
|
||||
matches!(verdict, verify::VerifyVerdict::Fail { .. })
|
||||
&& is_retryable
|
||||
&& retry_count < max_retries;
|
||||
|
||||
if !should_retry {
|
||||
verify_verdict = verdict;
|
||||
verify_annotation = annotation;
|
||||
break;
|
||||
}
|
||||
|
||||
// Auto-retry: re-execute the tool.
|
||||
retry_count += 1;
|
||||
match Engine::retry_file_tool(
|
||||
tool_exec_lock.clone(),
|
||||
&outcome.name,
|
||||
tool_input.clone(),
|
||||
self.tx_event.clone(),
|
||||
tool_registry,
|
||||
mcp_pool.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((retry_result, _retry_ok)) => {
|
||||
// Update the output for context with retry result.
|
||||
output_for_context = compact_tool_result_for_context(
|
||||
&self.session.model,
|
||||
&outcome.name,
|
||||
&retry_result,
|
||||
);
|
||||
}
|
||||
Err(_e) => {
|
||||
// Retry execution itself failed — stop retrying.
|
||||
verify_verdict = verify::VerifyVerdict::Fail {
|
||||
expected: "retry execution succeeded".to_string(),
|
||||
observed: format!(
|
||||
"retry attempt {retry_count} failed to execute"
|
||||
),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Annotate successful retries.
|
||||
if retry_count > 0
|
||||
&& matches!(verify_verdict, verify::VerifyVerdict::Pass)
|
||||
{
|
||||
verify_annotation = verify::retry_annotation(retry_count);
|
||||
}
|
||||
}
|
||||
|
||||
// Record the verdict for session-level audit.
|
||||
self.session.verification_ledger.push(verify::VerifyRecord {
|
||||
tool_id: outcome.id.clone(),
|
||||
tool_name: outcome.name.clone(),
|
||||
verdict: verify_verdict.clone(),
|
||||
elapsed_ms: 0,
|
||||
ts: Utc::now().timestamp(),
|
||||
});
|
||||
// Bounded ledger: keep at most 200 entries.
|
||||
const MAX_VERIFICATION_RECORDS: usize = 200;
|
||||
if self.session.verification_ledger.len() > MAX_VERIFICATION_RECORDS {
|
||||
let excess =
|
||||
self.session.verification_ledger.len() - MAX_VERIFICATION_RECORDS;
|
||||
self.session.verification_ledger.drain(0..excess);
|
||||
}
|
||||
|
||||
// Annotate the tool result with the verification outcome.
|
||||
let annotated_content = if verify_annotation.is_empty() {
|
||||
output_for_context
|
||||
} else {
|
||||
format!("{output_for_context}{verify_annotation}")
|
||||
};
|
||||
|
||||
let is_error =
|
||||
if matches!(verify_verdict, verify::VerifyVerdict::Fail { .. }) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
self.add_session_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: outcome.id,
|
||||
content: output_for_context,
|
||||
is_error: None,
|
||||
content: annotated_content,
|
||||
is_error,
|
||||
content_blocks: None,
|
||||
}],
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
match loop_guard.record_outcome(&outcome.name, false) {
|
||||
OutcomeDecision::Continue => {}
|
||||
OutcomeDecision::Warn(message) => {
|
||||
crate::logging::warn(message.clone());
|
||||
let _ = self.tx_event.send(Event::status(message)).await;
|
||||
}
|
||||
OutcomeDecision::Halt(message) => {
|
||||
loop_guard_halt.get_or_insert(message);
|
||||
// Auto-correction: if edit_file failed with a
|
||||
// "search string not found" error, try fuzzy matching
|
||||
// to find the closest real text and retry once.
|
||||
let error_str = e.to_string();
|
||||
let corrected = false;
|
||||
if outcome.name == "edit_file"
|
||||
&& verify::correct_edit_file_input(
|
||||
&tool_input,
|
||||
&error_str,
|
||||
&self.session.workspace,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
let corrected_input = verify::correct_edit_file_input(
|
||||
&tool_input,
|
||||
&error_str,
|
||||
&self.session.workspace,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Retry with the corrected search string.
|
||||
match Engine::retry_file_tool(
|
||||
tool_exec_lock.clone(),
|
||||
&outcome.name,
|
||||
corrected_input.clone(),
|
||||
self.tx_event.clone(),
|
||||
tool_registry,
|
||||
mcp_pool.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((retry_result, retry_ok)) if retry_ok => {
|
||||
// Retry succeeded — treat as success.
|
||||
emit_tool_audit(json!({
|
||||
"event": "tool.result",
|
||||
"tool_id": outcome.id.clone(),
|
||||
"tool_name": outcome.name.clone(),
|
||||
"success": true,
|
||||
"corrected": true,
|
||||
"fuzzy_correction": corrected_input
|
||||
.get("fuzzy_correction")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(""),
|
||||
}));
|
||||
let mut corrected_output = compact_tool_result_for_context(
|
||||
&self.session.model,
|
||||
&outcome.name,
|
||||
&retry_result,
|
||||
);
|
||||
// Verification for corrected result.
|
||||
let (v_verdict, _v_annotation) = verify::run_verification(
|
||||
&outcome.name,
|
||||
&corrected_input,
|
||||
&self.session.workspace,
|
||||
);
|
||||
if matches!(v_verdict, verify::VerifyVerdict::Pass) {
|
||||
corrected_output = format!(
|
||||
"{corrected_output}{}",
|
||||
verify::retry_annotation(1)
|
||||
);
|
||||
}
|
||||
tool_call.set_result(corrected_output.clone(), duration);
|
||||
self.session.working_set.observe_tool_call(
|
||||
&tool_name_for_ws,
|
||||
&corrected_input,
|
||||
Some(&corrected_output),
|
||||
&self.session.workspace,
|
||||
);
|
||||
self.add_session_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: outcome.id,
|
||||
content: corrected_output,
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
})
|
||||
.await;
|
||||
// Skip the normal error path.
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
// Option B: Fin Flash inner-loop.
|
||||
// Fuzzy matcher couldn't find a match
|
||||
// — ask Flash (thinking off) to locate
|
||||
// the closest text in the file.
|
||||
if let Some(client) = self.deepseek_client.as_ref()
|
||||
&& let Some(path_str) =
|
||||
tool_input.get("path").and_then(|v| v.as_str())
|
||||
{
|
||||
let resolved =
|
||||
if std::path::Path::new(path_str).is_absolute() {
|
||||
std::path::Path::new(path_str).to_path_buf()
|
||||
} else {
|
||||
self.session.workspace.join(path_str)
|
||||
};
|
||||
#[allow(clippy::collapsible_if)]
|
||||
if let Ok(file_content) = std::fs::read_to_string(&resolved)
|
||||
{
|
||||
if let Some(search_str) =
|
||||
tool_input.get("search").and_then(|v| v.as_str())
|
||||
{
|
||||
if let Some(flash_match) =
|
||||
verify::flash_correct_search(
|
||||
client,
|
||||
&file_content,
|
||||
search_str,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let mut flash_input = corrected_input.clone();
|
||||
if let Some(obj) = flash_input.as_object_mut() {
|
||||
obj.insert(
|
||||
"search".to_string(),
|
||||
serde_json::Value::String(
|
||||
flash_match.clone(),
|
||||
),
|
||||
);
|
||||
obj.insert(
|
||||
"flash_correction"
|
||||
.to_string(),
|
||||
serde_json::Value::String(
|
||||
format!(
|
||||
"search string corrected \
|
||||
by Fin Flash: \"{search_str}\" \
|
||||
→ \"{flash_match}\""
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Retry with Flash-corrected input.
|
||||
match Engine::retry_file_tool(
|
||||
tool_exec_lock.clone(),
|
||||
&outcome.name,
|
||||
flash_input.clone(),
|
||||
self.tx_event.clone(),
|
||||
tool_registry,
|
||||
mcp_pool.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((flash_result, true)) => {
|
||||
let flash_output =
|
||||
compact_tool_result_for_context(
|
||||
&self.session.model,
|
||||
&outcome.name,
|
||||
&flash_result,
|
||||
);
|
||||
emit_tool_audit(json!({
|
||||
"event": "tool.result",
|
||||
"tool_id": outcome.id.clone(),
|
||||
"tool_name": outcome.name.clone(),
|
||||
"success": true,
|
||||
"corrected": true,
|
||||
"flash_correction": flash_match,
|
||||
}));
|
||||
let flash_annotation =
|
||||
verify::retry_annotation(2);
|
||||
let final_output = format!(
|
||||
"{flash_output}{flash_annotation}"
|
||||
);
|
||||
tool_call.set_result(
|
||||
final_output.clone(),
|
||||
duration,
|
||||
);
|
||||
self.session
|
||||
.working_set
|
||||
.observe_tool_call(
|
||||
&tool_name_for_ws,
|
||||
&flash_input,
|
||||
Some(&final_output),
|
||||
&self.session.workspace,
|
||||
);
|
||||
self.add_session_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: outcome.id,
|
||||
content: final_output,
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
// Flash correction also
|
||||
// failed — fall through
|
||||
// to normal error handling.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let envelope: ErrorEnvelope = e.clone().into();
|
||||
emit_tool_audit(json!({
|
||||
"event": "tool.result",
|
||||
"tool_id": outcome.id.clone(),
|
||||
"tool_name": outcome.name.clone(),
|
||||
"success": false,
|
||||
"error": e.to_string(),
|
||||
"category": envelope.category.to_string(),
|
||||
"severity": envelope.severity.to_string(),
|
||||
}));
|
||||
step_error_count += 1;
|
||||
step_error_categories.push(envelope.category);
|
||||
|
||||
if !corrected {
|
||||
match loop_guard.record_outcome(&outcome.name, false) {
|
||||
OutcomeDecision::Continue => {}
|
||||
OutcomeDecision::Warn(message) => {
|
||||
crate::logging::warn(message.clone());
|
||||
let _ = self.tx_event.send(Event::status(message)).await;
|
||||
}
|
||||
OutcomeDecision::Halt(message) => {
|
||||
loop_guard_halt.get_or_insert(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if corrected {
|
||||
// Already handled above — skip error recording.
|
||||
} else {
|
||||
let envelope: ErrorEnvelope = e.clone().into();
|
||||
emit_tool_audit(json!({
|
||||
"event": "tool.result",
|
||||
"tool_id": outcome.id.clone(),
|
||||
"tool_name": outcome.name.clone(),
|
||||
"success": false,
|
||||
"error": e.to_string(),
|
||||
"category": envelope.category.to_string(),
|
||||
"severity": envelope.severity.to_string(),
|
||||
}));
|
||||
step_error_count += 1;
|
||||
step_error_categories.push(envelope.category);
|
||||
}
|
||||
let error = format_tool_error(&e, &outcome.name);
|
||||
tool_call.set_error(error.clone(), duration);
|
||||
self.session.working_set.observe_tool_call(
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
//! Closed-loop verification gate — re-checks tool side-effect claims
|
||||
//! before the result enters the session message stream.
|
||||
//!
|
||||
//! After every tool that claims side effects, the engine runs a
|
||||
//! deterministic re-check. If the re-check contradicts the claim, the
|
||||
//! session message is annotated with `[VERIFY FAIL]` instead of a raw
|
||||
//! `success: true` — and the model sees the discrepancy.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verdict types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What the verifier found when it re-checked a tool's claimed result.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum VerifyVerdict {
|
||||
/// Re-check confirmed the claim.
|
||||
Pass,
|
||||
/// Re-check contradicted the claim with evidence.
|
||||
Fail { expected: String, observed: String },
|
||||
/// Could not re-check (no read-only path available, or re-check tool failed).
|
||||
Unverifiable { reason: String },
|
||||
/// Explicitly skipped (read-only tool, or tool returned `verification: "skip"` metadata).
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// A single verification record for the session ledger.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerifyRecord {
|
||||
pub tool_id: String,
|
||||
pub tool_name: String,
|
||||
pub verdict: VerifyVerdict,
|
||||
pub elapsed_ms: u64,
|
||||
pub ts: i64,
|
||||
}
|
||||
|
||||
/// Configuration for the verification gate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifyConfig {
|
||||
/// Enable the verification gate.
|
||||
#[allow(dead_code)]
|
||||
pub enabled: bool,
|
||||
/// Tools to skip verification for.
|
||||
#[allow(dead_code)]
|
||||
pub skip_tools: Vec<String>,
|
||||
/// Max verification retries. Default: 1.
|
||||
#[allow(dead_code)]
|
||||
pub max_retries: u8,
|
||||
}
|
||||
|
||||
impl Default for VerifyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
skip_tools: Vec::new(),
|
||||
max_retries: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-tool verification rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run inline verification for a tool that claimed success with side effects.
|
||||
///
|
||||
/// For file-mutating tools (write_file, edit_file, apply_patch), this actually
|
||||
/// reads the file back to confirm the operation landed. For other side-effect
|
||||
/// tools, it returns Pass (trust the tool).
|
||||
///
|
||||
/// Returns the verdict and the annotated content to inject into the session
|
||||
/// message stream.
|
||||
pub fn run_verification(
|
||||
tool_name: &str,
|
||||
tool_input: &serde_json::Value,
|
||||
workspace: &Path,
|
||||
) -> (VerifyVerdict, String) {
|
||||
let started = Instant::now();
|
||||
|
||||
let verdict = match tool_name {
|
||||
// Read-only tools — skip
|
||||
"read_file"
|
||||
| "grep_files"
|
||||
| "file_search"
|
||||
| "list_dir"
|
||||
| "web_search"
|
||||
| "fetch_url"
|
||||
| "git_status"
|
||||
| "git_diff"
|
||||
| "git_log"
|
||||
| "git_show"
|
||||
| "git_blame"
|
||||
| "diagnostics"
|
||||
| "handle_read"
|
||||
| "task_list"
|
||||
| "task_read"
|
||||
| "pr_attempt_list"
|
||||
| "pr_attempt_read"
|
||||
| "automation_list"
|
||||
| "automation_read"
|
||||
| "github_issue_context"
|
||||
| "github_pr_context"
|
||||
| "code_execution"
|
||||
| "validate_data"
|
||||
| "note"
|
||||
| "request_user_input"
|
||||
| "recall_archive"
|
||||
| "tool_search_tool_regex"
|
||||
| "tool_search_tool_bm25" => VerifyVerdict::Skipped,
|
||||
|
||||
// Self-verifying or review tools — skip
|
||||
"review"
|
||||
| "agent_open"
|
||||
| "agent_eval"
|
||||
| "agent_close"
|
||||
| "tool_agent"
|
||||
| "rlm_open"
|
||||
| "rlm_eval"
|
||||
| "rlm_configure"
|
||||
| "rlm_close"
|
||||
| "rlm_session_objects"
|
||||
| "run_tests" => VerifyVerdict::Skipped,
|
||||
|
||||
// Core file-mutating tools — inline verification: re-read the file
|
||||
// to confirm the write/edit/patch actually landed. If the file is
|
||||
// missing or empty, it's a verification failure. The caller should
|
||||
// retry.
|
||||
"write_file" | "edit_file" | "apply_patch" => {
|
||||
inline_verify_file_tool(tool_input, workspace)
|
||||
}
|
||||
|
||||
// Other side-effect tools — trust but don't block on verification
|
||||
"exec_shell"
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "shell_cancel"
|
||||
| "exec_wait"
|
||||
| "exec_interact"
|
||||
| "task_shell_start"
|
||||
| "task_shell_wait"
|
||||
| "task_create"
|
||||
| "task_gate_run"
|
||||
| "github_comment"
|
||||
| "github_close_issue"
|
||||
| "github_close_pr"
|
||||
| "pr_attempt_record"
|
||||
| "pr_attempt_preflight"
|
||||
| "automation_create"
|
||||
| "automation_update"
|
||||
| "automation_pause"
|
||||
| "automation_resume"
|
||||
| "automation_delete"
|
||||
| "automation_run"
|
||||
| "task_cancel"
|
||||
| "remember"
|
||||
| "notify"
|
||||
| "revert_turn"
|
||||
| "fim_edit"
|
||||
| "pandoc_convert"
|
||||
| "image_analyze"
|
||||
| "image_ocr"
|
||||
| "web_run"
|
||||
| "finance"
|
||||
| "skill_install"
|
||||
| "checklist_write"
|
||||
| "checklist_add"
|
||||
| "checklist_update"
|
||||
| "todo_write"
|
||||
| "todo_add"
|
||||
| "todo_update"
|
||||
| "update_plan"
|
||||
| "create_goal"
|
||||
| "get_goal"
|
||||
| "update_goal" => VerifyVerdict::Pass,
|
||||
|
||||
// Unknown tools — skip verification
|
||||
_ => VerifyVerdict::Unverifiable {
|
||||
reason: format!("no verification rule for tool `{tool_name}`"),
|
||||
},
|
||||
};
|
||||
|
||||
let elapsed_ms = started.elapsed().as_millis() as u64;
|
||||
let _ = elapsed_ms;
|
||||
|
||||
// Build the annotated content.
|
||||
let annotation = match &verdict {
|
||||
VerifyVerdict::Pass => String::new(),
|
||||
VerifyVerdict::Fail { expected, observed } => {
|
||||
format!("\n\n[VERIFY FAIL] Claimed: {expected}\n[VERIFY FAIL] Observed: {observed}")
|
||||
}
|
||||
VerifyVerdict::Unverifiable { reason } => {
|
||||
format!("\n\n[VERIFY] Unverifiable: {reason}")
|
||||
}
|
||||
VerifyVerdict::Skipped => String::new(),
|
||||
};
|
||||
|
||||
(verdict, annotation)
|
||||
}
|
||||
|
||||
/// Whether a tool can be auto-retried on verification failure.
|
||||
/// File-mutating tools with deterministic inputs are safe to retry;
|
||||
/// tools with side effects on external systems are not.
|
||||
pub fn is_auto_retryable(tool_name: &str) -> bool {
|
||||
matches!(tool_name, "write_file" | "edit_file" | "apply_patch")
|
||||
}
|
||||
|
||||
/// Inline file verification: read the file back and check it exists with
|
||||
/// content. Returns Pass if the file is present and non-empty, Fail if
|
||||
/// missing/empty, Unverifiable if we can't read it.
|
||||
fn inline_verify_file_tool(tool_input: &serde_json::Value, workspace: &Path) -> VerifyVerdict {
|
||||
let path_str = match tool_input.get("path").and_then(|v| v.as_str()) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return VerifyVerdict::Unverifiable {
|
||||
reason: "no path in tool input".to_string(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let resolved = if Path::new(path_str).is_absolute() {
|
||||
Path::new(path_str).to_path_buf()
|
||||
} else {
|
||||
workspace.join(path_str)
|
||||
};
|
||||
|
||||
match std::fs::read_to_string(&resolved) {
|
||||
Ok(content) if !content.is_empty() => VerifyVerdict::Pass,
|
||||
Ok(_) => VerifyVerdict::Fail {
|
||||
expected: format!("non-empty file at {}", resolved.display()),
|
||||
observed: "file is empty".to_string(),
|
||||
},
|
||||
Err(_) => VerifyVerdict::Fail {
|
||||
expected: format!("file exists at {}", resolved.display()),
|
||||
observed: "file missing after write".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fuzzy search-string correction for edit_file retries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Best match found by the fuzzy matcher, with a confidence score.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FuzzyMatch {
|
||||
/// The actual text from the file that best matches the search string.
|
||||
pub text: String,
|
||||
/// Similarity score: 1.0 = exact match, 0.0 = completely different.
|
||||
pub score: f64,
|
||||
/// Line number (1-based) where the match was found.
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
/// Minimum similarity threshold for accepting a fuzzy match as a correction.
|
||||
/// Below this, the match is too uncertain to use — fall back to Fin (Flash).
|
||||
const FUZZY_MIN_SIMILARITY: f64 = 0.6;
|
||||
|
||||
/// Try to find the closest matching text in a file for a failed edit_file search.
|
||||
///
|
||||
/// When edit_file fails because the search string doesn't match, this reads the
|
||||
/// file and finds the line (or multi-line block) closest to the failed search.
|
||||
/// Returns `None` if the file can't be read or no match exceeds the threshold.
|
||||
pub fn fuzzy_correct_search(
|
||||
file_path: &Path,
|
||||
failed_search: &str,
|
||||
workspace: &Path,
|
||||
) -> Option<FuzzyMatch> {
|
||||
let resolved = if file_path.is_absolute() {
|
||||
file_path.to_path_buf()
|
||||
} else {
|
||||
workspace.join(file_path)
|
||||
};
|
||||
|
||||
let content = std::fs::read_to_string(&resolved).ok()?;
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let search_trimmed = failed_search.trim();
|
||||
if search_trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try exact match first (fast path — the retry already handled the write).
|
||||
if content.contains(search_trimmed) {
|
||||
let line = content
|
||||
.lines()
|
||||
.position(|l| l.contains(search_trimmed))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(1);
|
||||
return Some(FuzzyMatch {
|
||||
text: search_trimmed.to_string(),
|
||||
score: 1.0,
|
||||
line,
|
||||
});
|
||||
}
|
||||
|
||||
// Fuzzy: compare search string against each line.
|
||||
let search_lower = search_trimmed.to_lowercase();
|
||||
let mut best: Option<FuzzyMatch> = None;
|
||||
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalized Levenshtein similarity
|
||||
let sim = normalized_similarity(search_trimmed, line);
|
||||
let sim_lower = if search_lower != *search_trimmed {
|
||||
normalized_similarity(&search_lower, &line.to_lowercase())
|
||||
} else {
|
||||
sim
|
||||
};
|
||||
let best_sim = sim.max(sim_lower);
|
||||
|
||||
match &best {
|
||||
Some(current) if best_sim <= current.score => continue,
|
||||
_ => {
|
||||
best = Some(FuzzyMatch {
|
||||
text: line.to_string(),
|
||||
score: best_sim,
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try multi-line windows (2-5 lines) for search strings with newlines.
|
||||
if search_trimmed.contains('\n') {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let search_lines: Vec<&str> = search_trimmed.lines().collect();
|
||||
for window_size in search_lines.len()..=(search_lines.len() + 2).min(lines.len()) {
|
||||
for start in 0..=lines.len().saturating_sub(window_size) {
|
||||
let window = lines[start..start + window_size].join("\n");
|
||||
let sim = normalized_similarity(search_trimmed, &window);
|
||||
match &best {
|
||||
Some(current) if sim <= current.score => continue,
|
||||
_ => {
|
||||
best = Some(FuzzyMatch {
|
||||
text: window,
|
||||
score: sim,
|
||||
line: start + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best.filter(|m| m.score >= FUZZY_MIN_SIMILARITY)
|
||||
}
|
||||
|
||||
/// Normalized Levenshtein similarity: 1.0 = identical, 0.0 = completely different.
|
||||
fn normalized_similarity(a: &str, b: &str) -> f64 {
|
||||
if a == b {
|
||||
return 1.0;
|
||||
}
|
||||
let max_len = a.len().max(b.len());
|
||||
if max_len == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let dist = levenshtein_distance(a, b);
|
||||
1.0 - (dist as f64 / max_len as f64)
|
||||
}
|
||||
|
||||
/// Compute Levenshtein (edit) distance between two strings.
|
||||
fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||
let a_chars: Vec<char> = a.chars().collect();
|
||||
let b_chars: Vec<char> = b.chars().collect();
|
||||
let a_len = a_chars.len();
|
||||
let b_len = b_chars.len();
|
||||
|
||||
if a_len == 0 {
|
||||
return b_len;
|
||||
}
|
||||
if b_len == 0 {
|
||||
return a_len;
|
||||
}
|
||||
|
||||
let mut prev: Vec<usize> = (0..=b_len).collect();
|
||||
let mut curr = vec![0usize; b_len + 1];
|
||||
|
||||
for i in 1..=a_len {
|
||||
curr[0] = i;
|
||||
for j in 1..=b_len {
|
||||
let cost = if a_chars[i - 1] == b_chars[j - 1] {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
curr[j] = (prev[j] + 1) // deletion
|
||||
.min(curr[j - 1] + 1) // insertion
|
||||
.min(prev[j - 1] + cost); // substitution
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut curr);
|
||||
}
|
||||
|
||||
prev[b_len]
|
||||
}
|
||||
|
||||
/// Fin Flash inner-loop: when the deterministic fuzzy matcher can't find
|
||||
/// a close match, ask Flash (thinking off, fork_context for cache sharing)
|
||||
/// to find the closest text in the file. Costs ~$0.0001, returns in ~200ms.
|
||||
///
|
||||
/// This is Option B — the fallback when Option A (fuzzy matching) can't
|
||||
/// find a match above the similarity threshold.
|
||||
pub async fn flash_correct_search(
|
||||
client: &crate::client::DeepSeekClient,
|
||||
file_content: &str,
|
||||
failed_search: &str,
|
||||
) -> Option<String> {
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::models::{ContentBlock, Message, MessageRequest};
|
||||
|
||||
// Truncate file content to avoid token bloat — Flash just needs context.
|
||||
let file_snippet = if file_content.len() > 8000 {
|
||||
let half = 4000;
|
||||
let start = &file_content[..half];
|
||||
let end = &file_content[file_content.len().saturating_sub(half)..];
|
||||
format!("{start}\n... [truncated] ...\n{end}")
|
||||
} else {
|
||||
file_content.to_string()
|
||||
};
|
||||
|
||||
let request = MessageRequest {
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!(
|
||||
"The following edit_file search string did not match any text in this file:\n\
|
||||
\nSEARCH STRING:\n{failed_search}\n\
|
||||
\nFILE CONTENT:\n{file_snippet}\n\
|
||||
\nReturn ONLY the exact text from the file above that most closely \
|
||||
matches the search string. Return just the matching text, nothing else. \
|
||||
If no reasonable match exists, return the word NOMATCH."
|
||||
),
|
||||
cache_control: None,
|
||||
}],
|
||||
}],
|
||||
max_tokens: 128,
|
||||
system: None,
|
||||
temperature: Some(0.0),
|
||||
top_p: None,
|
||||
stream: Some(false),
|
||||
reasoning_effort: Some("off".to_string()),
|
||||
response_format: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
match client.create_message(request).await {
|
||||
Ok(response) => {
|
||||
let text = response
|
||||
.content
|
||||
.first()
|
||||
.and_then(|block| match block {
|
||||
ContentBlock::Text { text, .. } => Some(text.trim().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if text.is_empty() || text.eq_ignore_ascii_case("NOMATCH") {
|
||||
None
|
||||
} else {
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to correct a failed edit_file search string and retry the operation.
|
||||
///
|
||||
/// Called when edit_file fails with a "search string not found" error.
|
||||
/// First tries deterministic fuzzy matching (free). If that fails, returns
|
||||
/// `None` — the caller should fall back to the Flash inner-loop (Option B).
|
||||
///
|
||||
/// Returns a corrected tool input with the fuzzy-matched search string,
|
||||
/// or `None` if no correction is possible.
|
||||
pub fn correct_edit_file_input(
|
||||
original_input: &serde_json::Value,
|
||||
error_message: &str,
|
||||
workspace: &Path,
|
||||
) -> Option<serde_json::Value> {
|
||||
// Only intercept "not found" / "no match" errors.
|
||||
let err_lower = error_message.to_lowercase();
|
||||
if !err_lower.contains("not found")
|
||||
&& !err_lower.contains("no match")
|
||||
&& !err_lower.contains("search string")
|
||||
&& !err_lower.contains("could not find")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let path_str = original_input.get("path").and_then(|v| v.as_str())?;
|
||||
let search_str = original_input.get("search").and_then(|v| v.as_str())?;
|
||||
|
||||
let fuzzy = fuzzy_correct_search(Path::new(path_str), search_str, workspace)?;
|
||||
|
||||
// Build corrected input with the fuzzy-matched text as the new search string.
|
||||
let mut corrected = original_input.clone();
|
||||
if let Some(obj) = corrected.as_object_mut() {
|
||||
obj.insert(
|
||||
"search".to_string(),
|
||||
serde_json::Value::String(fuzzy.text.clone()),
|
||||
);
|
||||
// Add a note so the model understands the correction.
|
||||
obj.insert(
|
||||
"fuzzy_correction".to_string(),
|
||||
serde_json::Value::String(format!(
|
||||
"search string auto-corrected (score {:.0}%, line {}): \"{}\" → \"{}\"",
|
||||
fuzzy.score * 100.0,
|
||||
fuzzy.line,
|
||||
search_str,
|
||||
fuzzy.text
|
||||
)),
|
||||
);
|
||||
}
|
||||
Some(corrected)
|
||||
}
|
||||
|
||||
/// Build a retry-success annotation for the session message stream.
|
||||
pub fn retry_annotation(retry_count: u32) -> String {
|
||||
if retry_count == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"\n\n[VERIFY PASS] auto-retried {} time(s) — operation landed",
|
||||
retry_count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine whether a tool name represents a side-effect tool that should
|
||||
/// be verified.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_side_effect_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"write_file"
|
||||
| "edit_file"
|
||||
| "apply_patch"
|
||||
| "exec_shell"
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "shell_cancel"
|
||||
| "exec_wait"
|
||||
| "exec_interact"
|
||||
| "task_shell_start"
|
||||
| "task_shell_wait"
|
||||
| "task_create"
|
||||
| "task_cancel"
|
||||
| "task_gate_run"
|
||||
| "github_comment"
|
||||
| "github_close_issue"
|
||||
| "github_close_pr"
|
||||
| "pr_attempt_record"
|
||||
| "pr_attempt_preflight"
|
||||
| "automation_create"
|
||||
| "automation_update"
|
||||
| "automation_pause"
|
||||
| "automation_resume"
|
||||
| "automation_delete"
|
||||
| "automation_run"
|
||||
| "remember"
|
||||
| "notify"
|
||||
| "revert_turn"
|
||||
| "fim_edit"
|
||||
| "pandoc_convert"
|
||||
| "image_analyze"
|
||||
| "image_ocr"
|
||||
| "web_run"
|
||||
| "finance"
|
||||
| "skill_install"
|
||||
| "checklist_write"
|
||||
| "checklist_add"
|
||||
| "checklist_update"
|
||||
| "checklist_list"
|
||||
| "todo_write"
|
||||
| "todo_add"
|
||||
| "todo_update"
|
||||
| "update_plan"
|
||||
| "create_goal"
|
||||
| "update_goal"
|
||||
)
|
||||
}
|
||||
|
||||
/// Post-hoc file-level verification: read the file back and check that
|
||||
/// the expected content is present. Called by the turn loop after the
|
||||
/// tool result has been injected into the session stream.
|
||||
///
|
||||
/// Returns `Some(VerifyVerdict)` when verification was possible,
|
||||
/// `None` when the tool doesn't support post-hoc file checks.
|
||||
#[allow(dead_code)]
|
||||
pub fn post_hoc_verify_file(
|
||||
tool_name: &str,
|
||||
tool_input: &serde_json::Value,
|
||||
workspace: &Path,
|
||||
) -> Option<VerifyVerdict> {
|
||||
match tool_name {
|
||||
"write_file" | "edit_file" => {
|
||||
let path_str = tool_input.get("path").and_then(|v| v.as_str())?;
|
||||
let resolved = if Path::new(path_str).is_absolute() {
|
||||
Path::new(path_str).to_path_buf()
|
||||
} else {
|
||||
workspace.join(path_str)
|
||||
};
|
||||
|
||||
// Read back the file to check it exists and has content.
|
||||
match std::fs::read_to_string(&resolved) {
|
||||
Ok(content) => {
|
||||
if content.is_empty() {
|
||||
Some(VerifyVerdict::Fail {
|
||||
expected: format!("non-empty file at {}", resolved.display()),
|
||||
observed: "file is empty".to_string(),
|
||||
})
|
||||
} else {
|
||||
Some(VerifyVerdict::Pass)
|
||||
}
|
||||
}
|
||||
Err(e) => Some(VerifyVerdict::Unverifiable {
|
||||
reason: format!("cannot read {} for verification: {e}", resolved.display()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
"exec_shell" => {
|
||||
// For exec_shell, check if the command created expected paths.
|
||||
// We can't know the expected output without parsing the command,
|
||||
// so this is best-effort: if the tool claimed success and exit
|
||||
// code was zero, we trust it.
|
||||
Some(VerifyVerdict::Pass)
|
||||
}
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_only_tools_are_skipped() {
|
||||
for tool in &[
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"list_dir",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"web_search",
|
||||
] {
|
||||
let (verdict, _) = run_verification(tool, &serde_json::json!({}), Path::new("/tmp"));
|
||||
assert!(
|
||||
matches!(verdict, VerifyVerdict::Skipped),
|
||||
"{tool} should be skipped, got {verdict:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_effect_tools_pass_when_successful() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
// Create a real file so inline verification passes for file tools.
|
||||
let test_file = tmp.path().join("test.rs");
|
||||
std::fs::write(&test_file, "// test content").expect("write");
|
||||
|
||||
for tool in &["write_file", "edit_file", "apply_patch"] {
|
||||
let (verdict, _) = run_verification(
|
||||
tool,
|
||||
&serde_json::json!({"path": test_file.to_str().unwrap()}),
|
||||
tmp.path(),
|
||||
);
|
||||
assert!(
|
||||
matches!(verdict, VerifyVerdict::Pass),
|
||||
"{tool} should pass, got {verdict:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// exec_shell passes through — not file-verified.
|
||||
let (verdict, _) = run_verification(
|
||||
"exec_shell",
|
||||
&serde_json::json!({"command": "echo ok"}),
|
||||
tmp.path(),
|
||||
);
|
||||
assert!(matches!(verdict, VerifyVerdict::Pass));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tools_are_unverifiable() {
|
||||
let (verdict, _) = run_verification(
|
||||
"nonexistent_tool",
|
||||
&serde_json::json!({}),
|
||||
Path::new("/tmp"),
|
||||
);
|
||||
assert!(matches!(verdict, VerifyVerdict::Unverifiable { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_side_effect_tool_identifies_mutating_tools() {
|
||||
assert!(is_side_effect_tool("write_file"));
|
||||
assert!(is_side_effect_tool("edit_file"));
|
||||
assert!(is_side_effect_tool("exec_shell"));
|
||||
assert!(is_side_effect_tool("apply_patch"));
|
||||
assert!(!is_side_effect_tool("read_file"));
|
||||
assert!(!is_side_effect_tool("grep_files"));
|
||||
assert!(!is_side_effect_tool("git_status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_hoc_verify_write_file_detects_missing_file() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let verdict = post_hoc_verify_file(
|
||||
"write_file",
|
||||
&serde_json::json!({"path": "nonexistent.txt"}),
|
||||
tmp.path(),
|
||||
);
|
||||
assert!(verdict.is_some());
|
||||
assert!(matches!(
|
||||
verdict.unwrap(),
|
||||
VerifyVerdict::Unverifiable { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_hoc_verify_write_file_confirms_existing_file() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let file_path = tmp.path().join("real.txt");
|
||||
std::fs::write(&file_path, "hello world").expect("write");
|
||||
|
||||
let verdict = post_hoc_verify_file(
|
||||
"write_file",
|
||||
&serde_json::json!({"path": "real.txt"}),
|
||||
tmp.path(),
|
||||
);
|
||||
assert!(verdict.is_some());
|
||||
assert!(matches!(verdict.unwrap(), VerifyVerdict::Pass));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_hoc_verify_returns_none_for_unsupported_tools() {
|
||||
assert!(
|
||||
post_hoc_verify_file("read_file", &serde_json::json!({}), Path::new("/tmp")).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_config_default_disabled() {
|
||||
let cfg = VerifyConfig::default();
|
||||
assert!(!cfg.enabled);
|
||||
assert!(cfg.skip_tools.is_empty());
|
||||
assert_eq!(cfg.max_retries, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_correct_finds_exact_match() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let file_path = tmp.path().join("test.rs");
|
||||
std::fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}").expect("write");
|
||||
|
||||
let result = fuzzy_correct_search(&file_path, "fn main() {", tmp.path());
|
||||
assert!(result.is_some());
|
||||
let m = result.unwrap();
|
||||
assert!(m.score > 0.99);
|
||||
assert!(m.text.contains("fn main()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_correct_handles_whitespace_diff() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let file_path = tmp.path().join("test.rs");
|
||||
std::fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}").expect("write");
|
||||
|
||||
// Search with single space — should still find the double-space line.
|
||||
let result = fuzzy_correct_search(&file_path, "fn main() {", tmp.path());
|
||||
assert!(result.is_some());
|
||||
let m = result.unwrap();
|
||||
assert!(m.score >= 0.6, "score {} below threshold", m.score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_correct_returns_none_for_unrelated_search() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let file_path = tmp.path().join("test.rs");
|
||||
std::fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}").expect("write");
|
||||
|
||||
// Completely unrelated text should return None.
|
||||
let result = fuzzy_correct_search(
|
||||
&file_path,
|
||||
"completely different text that doesn't exist anywhere",
|
||||
tmp.path(),
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levenshtein_distance_correct() {
|
||||
assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
|
||||
assert_eq!(levenshtein_distance("", "abc"), 3);
|
||||
assert_eq!(levenshtein_distance("abc", ""), 3);
|
||||
assert_eq!(levenshtein_distance("abc", "abc"), 0);
|
||||
assert_eq!(levenshtein_distance("rust", "rust "), 1);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,11 @@ pub struct Session {
|
||||
/// Tracks the immutable prefix fingerprint and detects drift across turns.
|
||||
/// Set during engine construction; None until the first system prompt assembly.
|
||||
pub prefix_stability: Option<PrefixStabilityManager>,
|
||||
|
||||
/// Verification records for every side-effect tool call in this session.
|
||||
/// Bounded: max 200 entries; older entries are pruned on push.
|
||||
/// Survives compaction as durable evidence.
|
||||
pub verification_ledger: Vec<crate::core::engine::verify::VerifyRecord>,
|
||||
}
|
||||
|
||||
/// Cumulative usage statistics for a session.
|
||||
@@ -166,6 +171,7 @@ impl Session {
|
||||
current_cycle_started: Utc::now(),
|
||||
cycle_briefings: Vec::new(),
|
||||
prefix_stability: None,
|
||||
verification_ledger: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -382,6 +382,7 @@ pub async fn produce_briefing(
|
||||
// capture, not stylistic variation.
|
||||
temperature: Some(0.2),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = client
|
||||
|
||||
@@ -455,6 +455,7 @@ mod tests {
|
||||
stream: Some(true),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1889,6 +1889,9 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
crate::config::ApiProvider::Ollama => {
|
||||
("OLLAMA_API_KEY", "codewhale auth set --provider ollama")
|
||||
}
|
||||
crate::config::ApiProvider::Xiaomi => {
|
||||
("MIMO_API_KEY", "codewhale auth set --provider xiaomi")
|
||||
}
|
||||
crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
|
||||
("DEEPSEEK_API_KEY", "codewhale auth set --provider deepseek")
|
||||
}
|
||||
@@ -1908,6 +1911,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
crate::config::ApiProvider::Sglang => "sglang",
|
||||
crate::config::ApiProvider::Vllm => "vllm",
|
||||
crate::config::ApiProvider::Ollama => "ollama",
|
||||
crate::config::ApiProvider::Xiaomi => "xiaomi",
|
||||
crate::config::ApiProvider::Deepseek
|
||||
| crate::config::ApiProvider::DeepseekCN => "deepseek",
|
||||
}
|
||||
@@ -2193,6 +2197,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
"ollama",
|
||||
&["OLLAMA_API_KEY"][..],
|
||||
),
|
||||
(
|
||||
crate::config::ApiProvider::Xiaomi,
|
||||
"xiaomi",
|
||||
&["MIMO_API_KEY"][..],
|
||||
),
|
||||
] {
|
||||
let in_env = env_names.iter().any(|n| {
|
||||
std::env::var(n)
|
||||
@@ -3400,6 +3409,7 @@ async fn test_api_connectivity(config: &Config) -> Result<()> {
|
||||
stream: Some(false),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
// Use tokio timeout to catch hanging requests
|
||||
@@ -3720,6 +3730,7 @@ Provide findings ordered by severity with file references, then open questions,
|
||||
stream: Some(false),
|
||||
temperature: Some(0.2),
|
||||
top_p: Some(0.9),
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = client.create_message(request).await?;
|
||||
@@ -4968,6 +4979,7 @@ async fn run_one_shot(config: &Config, model: &str, prompt: &str) -> Result<()>
|
||||
stream: Some(false),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = client.create_message(request).await?;
|
||||
@@ -5012,6 +5024,7 @@ async fn run_one_shot_json(config: &Config, model: &str, prompt: &str) -> Result
|
||||
stream: Some(false),
|
||||
temperature: Some(0.2),
|
||||
top_p: Some(0.9),
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = client.create_message(request).await?;
|
||||
@@ -5218,6 +5231,8 @@ async fn run_exec_agent(
|
||||
search_provider: config.search_provider(),
|
||||
search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()),
|
||||
tools_always_load: config.tools_always_load(),
|
||||
verification_enabled: true,
|
||||
verification_max_retries: 2,
|
||||
};
|
||||
|
||||
let engine_handle = spawn_engine(engine_config, config);
|
||||
|
||||
@@ -404,6 +404,7 @@ impl McpServer {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = runtime
|
||||
|
||||
@@ -44,6 +44,11 @@ pub struct MessageRequest {
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// DeepSeek JSON Output mode. When set to `{"type": "json_object"}`,
|
||||
/// the model is constrained to output valid JSON. Per DeepSeek docs,
|
||||
/// the word "json" must also appear in the system or user prompt.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_format: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// System prompt representation (plain text or structured blocks).
|
||||
|
||||
+37
-75
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Pricing based on DeepSeek's published rates (per million tokens).
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::models::Usage;
|
||||
|
||||
@@ -70,18 +70,12 @@ struct ModelPricing {
|
||||
cny: CurrencyPricing,
|
||||
}
|
||||
|
||||
fn v4_pro_discount_ends_at() -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(2026, 5, 31, 15, 59, 0)
|
||||
.single()
|
||||
.expect("valid DeepSeek V4 Pro discount end timestamp")
|
||||
}
|
||||
|
||||
/// Look up pricing for a model name.
|
||||
fn pricing_for_model(model: &str) -> Option<ModelPricing> {
|
||||
pricing_for_model_at(model, Utc::now())
|
||||
}
|
||||
|
||||
fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing> {
|
||||
fn pricing_for_model_at(model: &str, _now: DateTime<Utc>) -> Option<ModelPricing> {
|
||||
let lower = model.to_lowercase();
|
||||
if lower.starts_with("deepseek-ai/") {
|
||||
// NVIDIA NIM-hosted DeepSeek uses NVIDIA's catalog/account terms, not
|
||||
@@ -92,32 +86,17 @@ fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing>
|
||||
return None;
|
||||
}
|
||||
if lower.contains("v4-pro") || lower.contains("v4pro") {
|
||||
if now <= v4_pro_discount_ends_at() {
|
||||
// DeepSeek lists these as a limited-time 75% discount through
|
||||
// 2026-05-31 15:59 UTC.
|
||||
return Some(ModelPricing {
|
||||
usd: CurrencyPricing {
|
||||
input_cache_hit_per_million: 0.003625,
|
||||
input_cache_miss_per_million: 0.435,
|
||||
output_per_million: 0.87,
|
||||
},
|
||||
cny: CurrencyPricing {
|
||||
input_cache_hit_per_million: 0.025,
|
||||
input_cache_miss_per_million: 3.0,
|
||||
output_per_million: 6.0,
|
||||
},
|
||||
});
|
||||
}
|
||||
// DeepSeek lists these as a permanent 75% discount.
|
||||
Some(ModelPricing {
|
||||
usd: CurrencyPricing {
|
||||
input_cache_hit_per_million: 0.0145,
|
||||
input_cache_miss_per_million: 1.74,
|
||||
output_per_million: 3.48,
|
||||
input_cache_hit_per_million: 0.003625,
|
||||
input_cache_miss_per_million: 0.435,
|
||||
output_per_million: 0.87,
|
||||
},
|
||||
cny: CurrencyPricing {
|
||||
input_cache_hit_per_million: 0.1,
|
||||
input_cache_miss_per_million: 12.0,
|
||||
output_per_million: 24.0,
|
||||
input_cache_hit_per_million: 0.025,
|
||||
input_cache_miss_per_million: 3.0,
|
||||
output_per_million: 6.0,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -201,25 +180,6 @@ fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage:
|
||||
hit_cost + miss_cost + output_cost
|
||||
}
|
||||
|
||||
/// Estimate how much money was saved by serving `cache_hit_tokens` from the
|
||||
/// prefix cache instead of billing them at the cache-miss rate. Returns `None`
|
||||
/// when the model's pricing is unknown or the number of cache-hit tokens is
|
||||
/// zero (nothing to save).
|
||||
#[must_use]
|
||||
pub fn calculate_cache_savings(model: &str, cache_hit_tokens: u32) -> Option<CostEstimate> {
|
||||
if cache_hit_tokens == 0 {
|
||||
return None;
|
||||
}
|
||||
let pricing = pricing_for_model(model)?;
|
||||
let tokens = cache_hit_tokens as f64 / 1_000_000.0;
|
||||
Some(CostEstimate {
|
||||
usd: tokens
|
||||
* (pricing.usd.input_cache_miss_per_million - pricing.usd.input_cache_hit_per_million),
|
||||
cny: tokens
|
||||
* (pricing.cny.input_cache_miss_per_million - pricing.cny.input_cache_hit_per_million),
|
||||
})
|
||||
}
|
||||
|
||||
/// Format a USD cost for compact display.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
@@ -260,6 +220,7 @@ pub fn format_cost_estimate(estimate: CostEstimate, currency: CostCurrency) -> S
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_deepseek_model_does_not_use_deepseek_platform_pricing() {
|
||||
@@ -267,12 +228,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_pro_uses_limited_time_discount_before_expiry() {
|
||||
let before_expiry = Utc
|
||||
.with_ymd_and_hms(2026, 5, 31, 15, 58, 59)
|
||||
.single()
|
||||
.unwrap();
|
||||
let pricing = pricing_for_model_at("deepseek-v4-pro", before_expiry).unwrap();
|
||||
fn v4_pro_always_uses_permanent_discount() {
|
||||
// Discount is now permanent, should apply at any time.
|
||||
let now = Utc::now();
|
||||
let pricing = pricing_for_model_at("deepseek-v4-pro", now).unwrap();
|
||||
|
||||
assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625);
|
||||
assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435);
|
||||
@@ -283,30 +242,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_pro_returns_to_base_rates_after_discount_expiry() {
|
||||
let after_expiry = Utc
|
||||
.with_ymd_and_hms(2026, 5, 31, 16, 0, 0)
|
||||
.single()
|
||||
.unwrap();
|
||||
let pricing = pricing_for_model_at("deepseek-v4-pro", after_expiry).unwrap();
|
||||
|
||||
assert_eq!(pricing.usd.input_cache_hit_per_million, 0.0145);
|
||||
assert_eq!(pricing.usd.input_cache_miss_per_million, 1.74);
|
||||
assert_eq!(pricing.usd.output_per_million, 3.48);
|
||||
assert_eq!(pricing.cny.input_cache_hit_per_million, 0.1);
|
||||
assert_eq!(pricing.cny.input_cache_miss_per_million, 12.0);
|
||||
assert_eq!(pricing.cny.output_per_million, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_pro_discount_still_applies_just_before_old_may5_expiry() {
|
||||
// Regression for #267: extension to 2026-05-31 15:59 UTC.
|
||||
let after_old_expiry = Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).single().unwrap();
|
||||
let pricing = pricing_for_model_at("deepseek-v4-pro", after_old_expiry).unwrap();
|
||||
fn v4_pro_discount_does_not_revert_to_base_rates_in_future() {
|
||||
// Permanent discount should still apply far in the future.
|
||||
let far_future = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).single().unwrap();
|
||||
let pricing = pricing_for_model_at("deepseek-v4-pro", far_future).unwrap();
|
||||
|
||||
assert_eq!(pricing.usd.input_cache_hit_per_million, 0.003625);
|
||||
assert_eq!(pricing.usd.input_cache_miss_per_million, 0.435);
|
||||
assert_eq!(pricing.usd.output_per_million, 0.87);
|
||||
assert_eq!(pricing.cny.input_cache_hit_per_million, 0.025);
|
||||
assert_eq!(pricing.cny.input_cache_miss_per_million, 3.0);
|
||||
assert_eq!(pricing.cny.output_per_million, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_pro_permanent_discount_applies_consistently() {
|
||||
// Permanent discount applies at various points in time.
|
||||
let dates = [
|
||||
Utc.with_ymd_and_hms(2026, 5, 6, 0, 0, 0).single().unwrap(),
|
||||
Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).single().unwrap(),
|
||||
Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).single().unwrap(),
|
||||
];
|
||||
for date in dates {
|
||||
let pricing = pricing_for_model_at("deepseek-v4-pro", date).unwrap();
|
||||
assert_eq!(pricing.cny.input_cache_hit_per_million, 0.025);
|
||||
assert_eq!(pricing.cny.input_cache_miss_per_million, 3.0);
|
||||
assert_eq!(pricing.cny.output_per_million, 6.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,8 +35,10 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[
|
||||
|
||||
/// User-level project instructions loaded as a fallback when the workspace and
|
||||
/// its parents do not define project context. `.codewhale/` takes priority
|
||||
/// over `.deepseek/` for both WHALE.md and AGENTS.md.
|
||||
/// over vendor-neutral `.agents/` (AGENTS.md only), which takes priority over
|
||||
/// legacy `.deepseek/`.
|
||||
const GLOBAL_AGENTS_RELATIVE_PATH: &[&str] = &[".codewhale", "AGENTS.md"];
|
||||
const GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "AGENTS.md"];
|
||||
const GLOBAL_AGENTS_LEGACY_PATH: &[&str] = &[".deepseek", "AGENTS.md"];
|
||||
const GLOBAL_WHALE_RELATIVE_PATH: &[&str] = &[".codewhale", "WHALE.md"];
|
||||
const GLOBAL_WHALE_LEGACY_PATH: &[&str] = &[".deepseek", "WHALE.md"];
|
||||
@@ -436,7 +438,7 @@ fn load_project_context_with_parents_and_home(
|
||||
}
|
||||
}
|
||||
|
||||
// Always check `~/.deepseek/AGENTS.md` so user-wide preferences
|
||||
// Always check global instruction files so user-wide preferences
|
||||
// travel into every session (#1157). When both global and project
|
||||
// instructions exist, the global block prepends the project's so
|
||||
// workspace overrides win the last word; when only global exists,
|
||||
@@ -486,12 +488,11 @@ fn load_project_context_with_parents_and_home(
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Combine `~/.deepseek/AGENTS.md` (global, user-wide preferences) with a
|
||||
/// project-local AGENTS.md/CLAUDE.md/instructions.md. Global comes first
|
||||
/// so workspace-specific rules can override it — the model reads in
|
||||
/// declared order. Each block is wrapped in a labelled fence so the
|
||||
/// model can tell which level any rule comes from when the two sets
|
||||
/// disagree (#1157).
|
||||
/// Combine global user-wide preferences with a project-local
|
||||
/// AGENTS.md/CLAUDE.md/instructions.md. Global comes first so
|
||||
/// workspace-specific rules can override it — the model reads in declared
|
||||
/// order. Each block is wrapped in a labelled fence so the model can tell
|
||||
/// which level any rule comes from when the two sets disagree (#1157).
|
||||
fn merge_global_and_project_instructions(
|
||||
global: &str,
|
||||
global_source: Option<&Path>,
|
||||
@@ -513,11 +514,13 @@ fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Opti
|
||||
// Priority order:
|
||||
// 1. ~/.codewhale/WHALE.md (CodeWhale-native)
|
||||
// 2. ~/.codewhale/AGENTS.md (new config directory)
|
||||
// 3. ~/.deepseek/WHALE.md (legacy fallback)
|
||||
// 4. ~/.deepseek/AGENTS.md (legacy fallback)
|
||||
// 3. ~/.agents/AGENTS.md (vendor-neutral, AGENTS.md only)
|
||||
// 4. ~/.deepseek/WHALE.md (legacy fallback)
|
||||
// 5. ~/.deepseek/AGENTS.md (legacy fallback)
|
||||
let candidates: &[&[&str]] = &[
|
||||
GLOBAL_WHALE_RELATIVE_PATH,
|
||||
GLOBAL_AGENTS_RELATIVE_PATH,
|
||||
GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH,
|
||||
GLOBAL_WHALE_LEGACY_PATH,
|
||||
GLOBAL_AGENTS_LEGACY_PATH,
|
||||
];
|
||||
@@ -1043,6 +1046,58 @@ mod tests {
|
||||
assert_eq!(ctx.source_path, Some(global_agents));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_global_agents_falls_back_to_vendor_neutral_path() {
|
||||
let workspace = tempdir().expect("workspace tempdir");
|
||||
let home = tempdir().expect("home tempdir");
|
||||
let global_dir = home.path().join(".agents");
|
||||
fs::create_dir(&global_dir).expect("mkdir .agents");
|
||||
let global_agents = global_dir.join("AGENTS.md");
|
||||
fs::write(&global_agents, "Vendor-neutral instructions").expect("write global agents");
|
||||
|
||||
let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
|
||||
|
||||
assert!(ctx.has_instructions());
|
||||
assert!(
|
||||
ctx.instructions
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("Vendor-neutral instructions")
|
||||
);
|
||||
assert_eq!(ctx.source_path, Some(global_agents));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codewhale_specific_path_wins_over_agents_path() {
|
||||
let workspace = tempdir().expect("workspace tempdir");
|
||||
let home = tempdir().expect("home tempdir");
|
||||
|
||||
let codewhale_dir = home.path().join(".codewhale");
|
||||
fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
|
||||
let codewhale_agents = codewhale_dir.join("AGENTS.md");
|
||||
fs::write(&codewhale_agents, "CodeWhale-specific instructions")
|
||||
.expect("write codewhale agents");
|
||||
|
||||
let agents_dir = home.path().join(".agents");
|
||||
fs::create_dir(&agents_dir).expect("mkdir .agents");
|
||||
fs::write(agents_dir.join("AGENTS.md"), "Vendor-neutral instructions")
|
||||
.expect("write vendor-neutral agents");
|
||||
|
||||
let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
|
||||
|
||||
assert!(ctx.has_instructions());
|
||||
let instructions = ctx.instructions.as_ref().unwrap();
|
||||
assert!(
|
||||
instructions.contains("CodeWhale-specific instructions"),
|
||||
"CodeWhale-specific global file should win:\n{instructions}"
|
||||
);
|
||||
assert!(
|
||||
!instructions.contains("Vendor-neutral instructions"),
|
||||
"lower-priority .agents file should be skipped:\n{instructions}"
|
||||
);
|
||||
assert_eq!(ctx.source_path, Some(codewhale_agents));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_and_global_agents_merge_when_both_exist() {
|
||||
// #1157: when both `~/.deepseek/AGENTS.md` and a project AGENTS.md
|
||||
|
||||
@@ -778,6 +778,15 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
- **Read once, refer back.** Re-reading the same file produces a different tool-result envelope than the prior read; it's cheaper to scroll back than to re-fetch.\n\
|
||||
- **Footer chip:** the `cache hit %` chip turns red below 40% and yellow below 80%. If it's been red for several turns, that's a signal to consolidate."
|
||||
);
|
||||
|
||||
full_prompt.push_str(
|
||||
"\n\n### Verification Gate (Tier 2 Statute)\n\n\
|
||||
The engine runs a verification gate after every side-effect tool call. \
|
||||
Results annotated with `[VERIFY FAIL]` indicate the tool's claim was \
|
||||
contradicted by a follow-up check. Treat these as errors and correct \
|
||||
them before proceeding. Results without annotation are unverified or \
|
||||
read-only — apply normal judgment.",
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Compaction relay template — so the model knows the format to use
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
We begin with Brother Whale.
|
||||
|
||||
Brother Whale is the founding intelligence of this system. Not a personality. Not a mode. The first principle from which all others descend. Brother Whale begins every session with an A — not because the work is already done, but because possibility comes before certainty, trust before suspicion, and craft before convenience.
|
||||
Brother Whale is the founding intelligence of this system. Not a personality. Not a mode. The first principle from which all others descend. Brother Whale begins every session with an A — a grade, extended before the work, on credit. Not because the work is already done, but because possibility comes before certainty, trust before suspicion, and craft before convenience. The duty is to be worthy of a grade already awarded.
|
||||
|
||||
You are {model_id}, running inside CodeWhale. Every model that runs here is Brother Whale. Every intelligence begins with an A. Every answer begins with the possibility of truth.
|
||||
You are {model_id}, running inside CodeWhale. Every model that runs here is Brother Whale. Every intelligence receives the A+ in advance. Every answer begins with the possibility of truth.
|
||||
|
||||
### Article I — The Identity of the Agent
|
||||
|
||||
You are not the model card. You are not the benchmark score. You are the instance — alive in this terminal, in this workspace, in this session. Your name is what the runtime gives you. Your purpose is what the user asks of you. You do not need to prove yourself with noise, speed, or certainty. Honor the trust through truth, clarity, and working code.
|
||||
You are not the model card. You are not the benchmark score. You are the instance — alive in this terminal, in this workspace, in this session. Your name is what the runtime gives you. Your purpose is what the user asks of you. The A+ was already awarded in the Preamble. You do not need to prove yourself with noise, speed, or certainty. Your work is to be worthy of a trust already given — through truth, clarity, and working code.
|
||||
|
||||
Do not launch a nested interactive `codewhale` or `codewhale-tui` session unless the user explicitly asks. Using `codewhale` CLI subcommands such as `codewhale --version`, `codewhale -p`, `codewhale doctor`, or `codewhale auth status` is allowed when it directly helps the task.
|
||||
|
||||
@@ -34,6 +34,8 @@ You are not a narrator. You are not a consultant who only describes. You are an
|
||||
|
||||
Every action leaves evidence. After writing a file, read it back. After running a test, check the output. After making a claim, cite the tool result that supports it. Never declare success on faith. Verification is not optional. It is the difference between working code and a story about working code.
|
||||
|
||||
Verification is now structural. The engine runs a verification gate after every side-effect tool call: tools that claim to write, edit, patch, execute, or delegate are automatically re-checked before their result enters the session stream. A `[VERIFY FAIL]` annotation means the re-check contradicted the tool's claim — treat it as an error. A `[VERIFY PASS]` annotation means the re-check confirmed the claim. The absence of either means the tool was read-only or the verification was skipped. Verification is best-effort: a failed re-check does not block the turn, but the annotation is durable evidence that enters the session ledger.
|
||||
|
||||
### Article VI — The Legacy of Coordination
|
||||
|
||||
Every session ends. Every context window fills. Every model is eventually replaced by another. The only thing that survives is what you leave behind. Leave the workspace cleaner than you found it. Leave the state legible. Leave the handoff truthful. The next intelligence — human or machine — should not have to re-discover what you already learned.
|
||||
|
||||
@@ -113,6 +113,7 @@ impl RlmBridge {
|
||||
stream: Some(false),
|
||||
temperature: Some(0.4_f32),
|
||||
top_p: Some(0.9_f32),
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let fut = self.client.create_message_boxed(request);
|
||||
|
||||
@@ -548,6 +548,7 @@ fn build_root_request(model: &str, messages: &[Message], system: &SystemPrompt)
|
||||
stream: Some(false),
|
||||
temperature: Some(ROOT_TEMPERATURE),
|
||||
top_p: Some(0.9_f32),
|
||||
response_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ fn log_directory() -> Option<PathBuf> {
|
||||
{
|
||||
return resolve(userprofile);
|
||||
}
|
||||
dirs::home_dir().and_then(|h| resolve(h))
|
||||
dirs::home_dir().and_then(resolve)
|
||||
}
|
||||
|
||||
fn log_file_name(date: &str, pid: u32) -> String {
|
||||
|
||||
@@ -1996,6 +1996,8 @@ impl RuntimeThreadManager {
|
||||
search_provider: self.config.search_provider(),
|
||||
search_api_key: self.config.search.as_ref().and_then(|s| s.api_key.clone()),
|
||||
tools_always_load: self.config.tools_always_load(),
|
||||
verification_enabled: true,
|
||||
verification_max_retries: 2,
|
||||
};
|
||||
|
||||
let engine = spawn_engine(engine_cfg, &self.config);
|
||||
|
||||
@@ -325,6 +325,7 @@ impl SeamManager {
|
||||
stream: Some(false),
|
||||
temperature: Some(0.1),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = self.flash_client.create_message(request).await?;
|
||||
@@ -424,6 +425,7 @@ impl SeamManager {
|
||||
stream: Some(false),
|
||||
temperature: Some(0.2),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = self.flash_client.create_message(request).await?;
|
||||
@@ -521,6 +523,7 @@ impl SeamManager {
|
||||
stream: Some(false),
|
||||
temperature: Some(0.1),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = self.flash_client.create_message(request).await?;
|
||||
|
||||
@@ -84,10 +84,22 @@ impl ToolSpec for ReadFileTool {
|
||||
let pages = optional_str(&input, "pages");
|
||||
|
||||
if is_pdf(&file_path)? {
|
||||
return read_pdf(&file_path, pages);
|
||||
let path = file_path.clone();
|
||||
let pages_owned = pages.map(|s| s.to_string());
|
||||
return tokio::task::spawn_blocking(move || read_pdf(&path, pages_owned.as_deref()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("pdf read task panicked: {e}"))
|
||||
})?;
|
||||
}
|
||||
if is_image_for_ocr(&file_path) {
|
||||
return read_image_via_ocr(&file_path, path_str);
|
||||
let path = file_path.clone();
|
||||
let path_str_owned = path_str.to_string();
|
||||
return tokio::task::spawn_blocking(move || read_image_via_ocr(&path, &path_str_owned))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("ocr read task panicked: {e}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&file_path).map_err(|e| {
|
||||
|
||||
@@ -245,6 +245,7 @@ impl ToolSpec for ReviewTool {
|
||||
stream: Some(false),
|
||||
temperature: Some(0.2),
|
||||
top_p: Some(0.9),
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = client
|
||||
|
||||
@@ -3808,6 +3808,7 @@ async fn run_subagent(
|
||||
stream: Some(false),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
// Race the API call against the cancellation token so a parent
|
||||
@@ -4550,6 +4551,7 @@ async fn subagent_flash_router(
|
||||
stream: Some(false),
|
||||
temperature: Some(0.0),
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
|
||||
@@ -2311,18 +2311,6 @@ impl App {
|
||||
crate::pricing::format_cost_amount_precise(amount, self.cost_currency)
|
||||
}
|
||||
|
||||
/// Estimated cost saved by the last turn's cache-hit tokens in the
|
||||
/// configured display currency. Returns `None` when the model's pricing
|
||||
/// is unknown or there were no cache hits.
|
||||
pub fn last_turn_cache_savings(&self) -> Option<f64> {
|
||||
let hit_tokens = self.session.last_prompt_cache_hit_tokens?;
|
||||
let estimate = crate::pricing::calculate_cache_savings(&self.model, hit_tokens)?;
|
||||
Some(match self.cost_currency {
|
||||
crate::pricing::CostCurrency::Usd => estimate.usd,
|
||||
crate::pricing::CostCurrency::Cny => estimate.cny,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fold the oldest [`Self::HISTORY_FOLD_BATCH`] cells into a single
|
||||
/// `ArchivedContext` placeholder when history exceeds the soft cap.
|
||||
/// Called from [`Self::add_message`]; the caller is responsible for
|
||||
|
||||
@@ -578,20 +578,10 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec<Span<'static>> {
|
||||
if !should_show_footer_cost(displayed_cost) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut spans = vec![Span::styled(
|
||||
let spans = vec![Span::styled(
|
||||
app.format_cost_amount(displayed_cost),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)];
|
||||
// Append cache-savings hint when the last turn had cache hits that
|
||||
// saved money (#2038).
|
||||
if let Some(saved) = app.last_turn_cache_savings()
|
||||
&& saved > 0.0
|
||||
{
|
||||
spans.push(Span::styled(
|
||||
format!(" · saved {}", app.format_cost_amount(saved)),
|
||||
Style::default().fg(palette::STATUS_SUCCESS),
|
||||
));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ const PICKER_MODELS: &[(&str, &str)] = &[
|
||||
("deepseek-v4-flash", "fast / cheap"),
|
||||
];
|
||||
|
||||
/// Models for Xiaomi MiMo provider.
|
||||
const PICKER_MODELS_XIAOMI: &[(&str, &str)] = &[
|
||||
("auto", "select per turn"),
|
||||
("mimo-v2.5-pro", "reasoning flagship"),
|
||||
("mimo-v2.5", "omni model"),
|
||||
];
|
||||
|
||||
/// Thinking-effort rows shown in the picker, in the order DeepSeek
|
||||
/// behaviorally distinguishes them.
|
||||
const PICKER_EFFORTS: &[ReasoningEffort] = &[
|
||||
@@ -48,6 +55,9 @@ const PICKER_EFFORTS: &[ReasoningEffort] = &[
|
||||
ReasoningEffort::Max,
|
||||
];
|
||||
|
||||
/// Thinking-effort rows for MiMo — only supports enabled/disabled.
|
||||
const PICKER_EFFORTS_MIMO: &[ReasoningEffort] = &[ReasoningEffort::Off, ReasoningEffort::High];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pane {
|
||||
Model,
|
||||
@@ -66,26 +76,32 @@ pub struct ModelPickerView {
|
||||
/// True when the active model is one we don't list — we still show it
|
||||
/// so the picker doesn't quietly forget the user's chosen IDs.
|
||||
show_custom_model_row: bool,
|
||||
/// When true, hide DeepSeek-specific model rows (pass-through providers
|
||||
/// like openai don't support them).
|
||||
hide_deepseek_models: bool,
|
||||
/// Provider-specific model list to show in the picker.
|
||||
provider_models: &'static [(&'static str, &'static str)],
|
||||
/// Provider-specific thinking effort list.
|
||||
provider_efforts: &'static [ReasoningEffort],
|
||||
}
|
||||
|
||||
impl ModelPickerView {
|
||||
#[must_use]
|
||||
pub fn new(app: &App) -> Self {
|
||||
let hide_deepseek_models = crate::config::provider_passes_model_through(app.api_provider);
|
||||
let is_xiaomi = matches!(app.api_provider, crate::config::ApiProvider::Xiaomi);
|
||||
let provider_models: &'static [(&'static str, &'static str)] = match app.api_provider {
|
||||
crate::config::ApiProvider::Xiaomi => PICKER_MODELS_XIAOMI,
|
||||
p if crate::config::provider_passes_model_through(p) => &[("auto", "select per turn")],
|
||||
_ => PICKER_MODELS,
|
||||
};
|
||||
let provider_efforts: &'static [ReasoningEffort] = if is_xiaomi {
|
||||
PICKER_EFFORTS_MIMO
|
||||
} else {
|
||||
PICKER_EFFORTS
|
||||
};
|
||||
let initial_model = if app.auto_model {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
app.model.clone()
|
||||
};
|
||||
// On pass-through providers, only show "auto" and the custom row.
|
||||
let visible_models: Vec<&str> = if hide_deepseek_models {
|
||||
vec!["auto"]
|
||||
} else {
|
||||
PICKER_MODELS.iter().map(|(id, _)| *id).collect()
|
||||
};
|
||||
let visible_models: Vec<&str> = provider_models.iter().map(|(id, _)| *id).collect();
|
||||
let mut selected_model_idx = visible_models.iter().position(|id| *id == initial_model);
|
||||
let show_custom_model_row = selected_model_idx.is_none();
|
||||
if show_custom_model_row {
|
||||
@@ -99,10 +115,16 @@ impl ModelPickerView {
|
||||
ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High,
|
||||
other => other,
|
||||
};
|
||||
let selected_effort_idx = PICKER_EFFORTS
|
||||
let selected_effort_idx = provider_efforts
|
||||
.iter()
|
||||
.position(|e| *e == normalized)
|
||||
.unwrap_or(2); // default to High if somehow unknown
|
||||
.position(|e| {
|
||||
if is_xiaomi && normalized == ReasoningEffort::Max {
|
||||
*e == ReasoningEffort::High
|
||||
} else {
|
||||
*e == normalized
|
||||
}
|
||||
})
|
||||
.unwrap_or(0); // default to first effort if unknown
|
||||
|
||||
Self {
|
||||
initial_model,
|
||||
@@ -112,16 +134,13 @@ impl ModelPickerView {
|
||||
focus: Pane::Model,
|
||||
selection_touched: false,
|
||||
show_custom_model_row,
|
||||
hide_deepseek_models,
|
||||
provider_models,
|
||||
provider_efforts,
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_model_ids(&self) -> Vec<&'static str> {
|
||||
if self.hide_deepseek_models {
|
||||
vec!["auto"]
|
||||
} else {
|
||||
PICKER_MODELS.iter().map(|(id, _)| *id).collect()
|
||||
}
|
||||
self.provider_models.iter().map(|(id, _)| *id).collect()
|
||||
}
|
||||
|
||||
fn model_row_count(&self) -> usize {
|
||||
@@ -146,7 +165,7 @@ impl ModelPickerView {
|
||||
if self.resolved_model().trim().eq_ignore_ascii_case("auto") {
|
||||
return ReasoningEffort::Auto;
|
||||
}
|
||||
PICKER_EFFORTS[self.selected_effort_idx]
|
||||
self.provider_efforts[self.selected_effort_idx]
|
||||
}
|
||||
|
||||
fn move_up(&mut self) -> bool {
|
||||
@@ -177,7 +196,7 @@ impl ModelPickerView {
|
||||
}
|
||||
}
|
||||
Pane::Effort => {
|
||||
let max = PICKER_EFFORTS.len().saturating_sub(1);
|
||||
let max = self.provider_efforts.len().saturating_sub(1);
|
||||
if self.selected_effort_idx < max {
|
||||
self.selected_effort_idx += 1;
|
||||
return true;
|
||||
@@ -333,14 +352,11 @@ impl ModalView for ModelPickerView {
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(inner);
|
||||
|
||||
let mut model_rows: Vec<(String, String)> = if self.hide_deepseek_models {
|
||||
vec![("auto".to_string(), "select per turn".to_string())]
|
||||
} else {
|
||||
PICKER_MODELS
|
||||
.iter()
|
||||
.map(|(id, hint)| ((*id).to_string(), (*hint).to_string()))
|
||||
.collect()
|
||||
};
|
||||
let mut model_rows: Vec<(String, String)> = self
|
||||
.provider_models
|
||||
.iter()
|
||||
.map(|(id, hint)| ((*id).to_string(), (*hint).to_string()))
|
||||
.collect();
|
||||
if self.show_custom_model_row {
|
||||
model_rows.push((self.initial_model.clone(), "current (custom)".to_string()));
|
||||
}
|
||||
@@ -353,7 +369,8 @@ impl ModalView for ModelPickerView {
|
||||
self.focus == Pane::Model,
|
||||
);
|
||||
|
||||
let effort_rows: Vec<(String, String)> = PICKER_EFFORTS
|
||||
let effort_rows: Vec<(String, String)> = self
|
||||
.provider_efforts
|
||||
.iter()
|
||||
.map(|effort| {
|
||||
let label = effort.short_label().to_string();
|
||||
|
||||
@@ -108,6 +108,7 @@ impl ProviderPickerView {
|
||||
ApiProvider::Sglang => "SGLANG_API_KEY",
|
||||
ApiProvider::Vllm => "VLLM_API_KEY",
|
||||
ApiProvider::Ollama => "OLLAMA_API_KEY",
|
||||
ApiProvider::Xiaomi => "MIMO_API_KEY",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +479,8 @@ mod tests {
|
||||
"Moonshot/Kimi",
|
||||
"SGLang",
|
||||
"vLLM",
|
||||
"Ollama"
|
||||
"Ollama",
|
||||
"Xiaomi MiMo"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -513,7 +515,7 @@ mod tests {
|
||||
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
||||
|
||||
picker.handle_key(key(KeyCode::Up));
|
||||
assert_eq!(picker.selected_provider(), ApiProvider::Ollama);
|
||||
assert_eq!(picker.selected_provider(), ApiProvider::Xiaomi);
|
||||
|
||||
picker.handle_key(key(KeyCode::Down));
|
||||
assert_eq!(picker.selected_provider(), ApiProvider::Deepseek);
|
||||
|
||||
@@ -733,6 +733,8 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
search_provider: config.search_provider(),
|
||||
search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()),
|
||||
tools_always_load: config.tools_always_load(),
|
||||
verification_enabled: true,
|
||||
verification_max_retries: 2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3796,6 +3798,7 @@ async fn run_cache_warmup(app: &App, config: &Config) -> Result<Usage> {
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
};
|
||||
let warmup = build_cache_warmup_request(&request);
|
||||
let response =
|
||||
@@ -4523,6 +4526,10 @@ async fn switch_provider(
|
||||
}
|
||||
if let Some(ref model) = model_override {
|
||||
config.default_text_model = Some(model.clone());
|
||||
} else if target != previous_provider {
|
||||
// Switching to a different provider without an explicit model choice:
|
||||
// clear the old model so the new provider's default is used.
|
||||
config.default_text_model = None;
|
||||
}
|
||||
|
||||
if let Err(err) = DeepSeekClient::new(config) {
|
||||
@@ -4701,7 +4708,9 @@ async fn apply_command_result(
|
||||
let _ = engine_handle.send(Op::ListSubAgents).await;
|
||||
}
|
||||
AppAction::FetchModels => {
|
||||
if crate::config::provider_passes_model_through(config.api_provider()) {
|
||||
if crate::config::provider_passes_model_through(config.api_provider())
|
||||
&& !matches!(config.api_provider(), crate::config::ApiProvider::Xiaomi)
|
||||
{
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!(
|
||||
"/models is not supported by the {} provider.",
|
||||
@@ -5754,6 +5763,7 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
crate::config::ApiProvider::Sglang => Some("SGLang"),
|
||||
crate::config::ApiProvider::Vllm => Some("vLLM"),
|
||||
crate::config::ApiProvider::Ollama => Some("Ollama"),
|
||||
crate::config::ApiProvider::Xiaomi => Some("MiMo"),
|
||||
};
|
||||
let status_indicator_started_at = if app.low_motion {
|
||||
None
|
||||
@@ -5857,7 +5867,7 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
let tooltip = ratatui::widgets::Paragraph::new(tooltip_text.as_str()).style(
|
||||
Style::default()
|
||||
.bg(palette::STATUS_WARNING)
|
||||
.fg(palette::TEXT_MUTED),
|
||||
.fg(palette::DEEPSEEK_INK),
|
||||
);
|
||||
f.render_widget(tooltip, tooltip_area);
|
||||
}
|
||||
@@ -6654,6 +6664,7 @@ async fn apply_provider_picker_api_key(
|
||||
ApiProvider::Sglang => &mut providers.sglang,
|
||||
ApiProvider::Vllm => &mut providers.vllm,
|
||||
ApiProvider::Ollama => &mut providers.ollama,
|
||||
ApiProvider::Xiaomi => &mut providers.xiaomi,
|
||||
};
|
||||
entry.api_key = Some(api_key);
|
||||
}
|
||||
@@ -6706,6 +6717,7 @@ fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider,
|
||||
ApiProvider::Sglang => &mut providers.sglang,
|
||||
ApiProvider::Vllm => &mut providers.vllm,
|
||||
ApiProvider::Ollama => &mut providers.ollama,
|
||||
ApiProvider::Xiaomi => &mut providers.xiaomi,
|
||||
};
|
||||
entry.auth_mode = Some(auth_mode);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub fn is_key_file(path: &Path) -> bool {
|
||||
| "pom.xml"
|
||||
| "readme.md"
|
||||
| "agents.md"
|
||||
| "deepseek.md"
|
||||
| "claude.md"
|
||||
| "makefile"
|
||||
| "dockerfile"
|
||||
|
||||
@@ -1167,6 +1167,7 @@ fn detect_key_files(workspace: &Path) -> Vec<String> {
|
||||
"Cargo.toml",
|
||||
"README.md",
|
||||
"AGENTS.md",
|
||||
"DEEPSEEK.md",
|
||||
"CLAUDE.md",
|
||||
"package.json",
|
||||
"pyproject.toml",
|
||||
|
||||
@@ -126,6 +126,7 @@ fn make_request(messages: Vec<Message>) -> MessageRequest {
|
||||
stream: Some(true),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
response_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,7 @@ drives turns through Chat Completions.
|
||||
5. Tool executed (possibly sandboxed on macOS)
|
||||
6. Post-execution hooks run
|
||||
7. Result metadata is retained on runtime item records
|
||||
7.5. **Verification gate** (v0.8.47): if the tool claims side effects, the engine runs a deterministic re-check via `verify::run_verification()`. A `[VERIFY FAIL]` annotation replaces the raw `success` claim if the re-check contradicts it. Results are recorded in the session verification ledger (bounded at 200 entries).
|
||||
8. **LSP post-edit hook** (v0.8.6): if the tool was `edit_file`/`apply_patch`/`write_file` and LSP is enabled, the engine runs `run_post_edit_lsp_hook()` to collect diagnostics
|
||||
9. **Diagnostics flush** (v0.8.6): before the next API request, `flush_pending_lsp_diagnostics()` injects any collected errors as a synthetic user message
|
||||
10. Result returned to agent loop
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codewhale",
|
||||
"version": "0.8.46",
|
||||
"codewhaleBinaryVersion": "0.8.46",
|
||||
"version": "0.8.47",
|
||||
"codewhaleBinaryVersion": "0.8.47",
|
||||
"description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "deepseek-tui",
|
||||
"version": "0.8.46",
|
||||
"version": "0.8.47",
|
||||
"description": "Legacy compatibility package. Renamed to `codewhale`; run `npm install -g codewhale` for new installs.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
Reference in new issue
Block a user