mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 04:29:24 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6bc351da | ||
|
|
ebdda09c29 | ||
|
|
f42f94207c | ||
|
|
d79178a926 | ||
|
|
bcf6ba9a8e | ||
|
|
7a85f182e2 | ||
|
|
f7fe5e09a5 | ||
|
|
24b8945010 | ||
|
|
82e4a564aa | ||
|
|
07ae792068 | ||
|
|
ba40ae4aac | ||
|
|
7f2c382343 | ||
|
|
017ac97d0d | ||
|
|
0a394e1587 | ||
|
|
fafb76063d | ||
|
|
9e7cfc951a | ||
|
|
1be18e691b | ||
|
|
ccc9554ef4 | ||
|
|
97f245d5ac | ||
|
|
19f8d83d3b | ||
|
|
67b232b063 | ||
|
|
41c54f08aa | ||
|
|
56ea63e2c6 | ||
|
|
94834b2eb4 | ||
|
|
dc8f8496d6 | ||
|
|
5c1086fe9e | ||
|
|
dff592bc6d | ||
|
|
29141bc89b | ||
|
|
fe4f261a5d | ||
|
|
7f0444d26b | ||
|
|
298f5c6c51 | ||
|
|
853a39138c | ||
|
|
6ad3727fa0 | ||
|
|
16f62f7abf | ||
|
|
d0dc26ce25 | ||
|
|
7d8c40893f | ||
|
|
411153c549 | ||
|
|
e06071c177 | ||
|
|
f377b0f9d2 | ||
|
|
f3df8f5f26 | ||
|
|
8323bedfb7 | ||
|
|
6c14e2e1f4 | ||
|
|
c4f9078712 | ||
|
|
d713032b9f | ||
|
|
d89b33330f | ||
|
|
ffa75f07e5 | ||
|
|
35595f8edc | ||
|
|
b7bd02d814 | ||
|
|
dc8e94d705 | ||
|
|
f4dbf828c9 | ||
|
|
cbcf35c1bd | ||
|
|
3db7f05c60 | ||
|
|
5c82f08c58 | ||
|
|
ea3cc3d0d2 | ||
|
|
b172b8d306 | ||
|
|
14bb2bfd98 | ||
|
|
fc4884b0c8 | ||
|
|
7b91169017 | ||
|
|
cc0ac49822 | ||
|
|
f2881e7e3d | ||
|
|
f269147286 | ||
|
|
ad76ee39ba | ||
|
|
351b309c7d | ||
|
|
37186c3d95 | ||
|
|
6021fc56c8 | ||
|
|
8bdf7005b8 |
No files matched your search
@@ -0,0 +1,111 @@
|
||||
You are working in /Volumes/VIXinSSD/deepseek-tui.
|
||||
|
||||
Goal:
|
||||
Continue improving deepseek-tui as a first-class DeepSeek V4 coding harness, building directly on the previous overnight slice (commit f190ab74 + merge aa356e8b + import fix f9ede57c, plus release commit d7944421 / tag v0.4.6). This time, ACTUALLY read /Volumes/VIXinSSD/mathcode — the previous run claimed it wasn't mounted and adapted patterns from the work-order description instead. mathcode IS mounted; the directory exists and is readable.
|
||||
|
||||
What already shipped (do not redo):
|
||||
- Slice A: setup --status, setup --clean (with --force gate), setup --tools, setup --plugins, doctor --json, init_tools_dir / init_plugins_dir scaffolding with frontmatter examples (# name: / # description: / # usage: in tools/example.sh; ----delimited YAML in plugins/example/PLUGIN.md). Code in crates/tui/src/main.rs.
|
||||
- Slice B: fake-wrapper hostility — TOOL_CALL_START_MARKERS/END_MARKERS + filter_tool_call_delta promoted to pub(crate); FAKE_WRAPPER_NOTICE + contains_fake_tool_wrapper(); per-turn fake_wrapper_notice_emitted flag in handle_deepseek_turn. Code in crates/tui/src/core/engine.rs. Locked by crates/tui/tests/protocol_recovery.rs.
|
||||
- Slice C: docs in docs/CONFIGURATION.md (Setup status, clean, and extension dirs section + Why the engine strips XML/[TOOL_CALL] text section) and README.md quick-checks block.
|
||||
- 37 new tests landed (23 unit + 14 integration). All green.
|
||||
|
||||
Read first (in this order):
|
||||
1. AGENTS.md
|
||||
2. git status --short --branch (expect clean tree on main, 0 ahead of origin/main; a stash from a prior session may exist — leave it alone)
|
||||
3. README.md
|
||||
4. docs/ARCHITECTURE.md
|
||||
5. docs/CONFIGURATION.md (note the new Setup status / extension dirs / Why the engine strips ... sections)
|
||||
6. docs/RUNTIME_API.md
|
||||
7. crates/tui/src/main.rs (large file; pay attention to the new run_setup_status, run_setup_clean, init_tools_dir, init_plugins_dir, run_doctor, run_doctor_json helpers — do not duplicate them)
|
||||
8. crates/tui/src/core/engine.rs (new: TOOL_CALL_START_MARKERS, TOOL_CALL_END_MARKERS, filter_tool_call_delta, FAKE_WRAPPER_NOTICE, contains_fake_tool_wrapper)
|
||||
9. crates/tui/src/core/engine/tests.rs
|
||||
10. crates/tui/tests/protocol_recovery.rs
|
||||
11. crates/tui/src/client.rs
|
||||
12. crates/tui/src/tui/widgets/mod.rs
|
||||
13. crates/tui/src/core/capacity.rs (existing compaction decisions — relevant for issue #6)
|
||||
|
||||
Then ACTUALLY read mathcode (it IS mounted):
|
||||
14. /Volumes/VIXinSSD/mathcode/README.md
|
||||
15. /Volumes/VIXinSSD/mathcode/setup.sh (~10KB; full status/clean/help flag matrix and error messages worth borrowing)
|
||||
16. /Volumes/VIXinSSD/mathcode/run (763B; thin wrapper that loads .env and dispatches)
|
||||
17. /Volumes/VIXinSSD/mathcode/.env.example (~6KB; clear provider/backend comments — note our codebase only loads .env via dotenvy::dotenv() in crates/tui/src/main.rs:475 and has no .env.example)
|
||||
18. /Volumes/VIXinSSD/mathcode/skills/README.md
|
||||
19. /Volumes/VIXinSSD/mathcode/plugins/README.md
|
||||
20. /Volumes/VIXinSSD/mathcode/tools/ (list contents; look for self-describing tool scripts)
|
||||
21. /Volumes/VIXinSSD/mathcode/bin/ (list contents)
|
||||
|
||||
Open GitHub issue spine (use as roadmap):
|
||||
- #6 Coherence as plain-language session health ← previous slice's recommended next step
|
||||
- #7 Long-session evals for coherence/context handling
|
||||
- #8 Workspace extraction/runtime seams
|
||||
- #9 Thread/turn/item protocol stability
|
||||
- #10 Sandboxing, approvals, server safety
|
||||
- #11 Skills/plugins/MCP installability and management (partly done; doctor + status now report tools/plugins; install/manage UX still missing)
|
||||
- #12 DeepSeek API key setup/provider drift (partly done; setup --status surfaces source — but no .env.example, no provider-aware setup wizard)
|
||||
- #13 Observability for agent quality/coherence events (partly done; fake-wrapper strip emits a status event — coherence ladder still missing)
|
||||
- #14 Whale/DeepSeek TUI design system
|
||||
- #15 Thin IDE companions over runtime API
|
||||
- #16 Public roadmap/docs rewrite
|
||||
|
||||
Pick 1-3 high-leverage slices that can be finished and tested tonight. Strongly preferred (in priority order):
|
||||
|
||||
Slice 1 (recommended — issue #6 + #13): Plain-language coherence ladder.
|
||||
- The runtime already emits compaction events (CompactionStarted/Completed/Failed) and capacity decisions live in crates/tui/src/core/capacity.rs.
|
||||
- Add an Event::CoherenceState variant with a small fixed ladder: Healthy / GettingCrowded / RefreshingContext / VerifyingRecentWork / ResettingPlan.
|
||||
- Emit it from one place (engine), driven by existing capacity decisions and compaction events. Do NOT add a new background task.
|
||||
- Surface it in the TUI footer as a single chip (right-aligned, terminal-native, no emoji) and on the runtime API thread shape.
|
||||
- Snapshot-test the footer chip for each state and unit-test the state transitions from a synthetic capacity event log.
|
||||
- Land docs in docs/RUNTIME_API.md (CoherenceState shape) and docs/CONFIGURATION.md (footer chip).
|
||||
|
||||
Slice 2 (issue #12 + mathcode reference): .env.example + setup wizard messages.
|
||||
- Adapt /Volumes/VIXinSSD/mathcode/.env.example structure (provider/backend grouping, comment style) to a deepseek-tui .env.example at repo root.
|
||||
- Cover: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL (global vs china), DEEPSEEK_MODEL, NVIDIA_API_KEY (NIM), NIM_BASE_URL, RUST_LOG, sandbox toggles.
|
||||
- Update setup --status to point at .env.example when no .env is found ("Run `cp .env.example .env` and edit").
|
||||
- Do NOT add a `run` wrapper — dotenvy handles .env loading inside the binary, the wrapper is redundant and introduces drift.
|
||||
- Lock the .env.example shape with a test that asserts every documented variable is referenced from at least one source file.
|
||||
|
||||
Slice 3 (issue #14 / TUI render polish): Whale/DeepSeek design tokens.
|
||||
- Extract the current TUI color/border/padding choices into a single deepseek_theme module (light + dark variants), without changing any visible output yet.
|
||||
- Add a snapshot test or two that lock the existing rendering of one plan cell and one tool cell.
|
||||
- Out of scope for tonight: actually changing colors. Goal is to make a future skin-swap a 5-line change.
|
||||
|
||||
DO NOT pick:
|
||||
- Anything that re-touches NIM provider, V4 reasoning context accounting, or tool-call routing — that work is finished and protected by tests.
|
||||
- Anything that re-introduces multi_tool_use.parallel.
|
||||
- Anything that introduces an alternate fake-wrapper code path (XML, [TOOL_CALL], <function_calls>, ```tool_code, ```python, etc.). The engine's contract is: API tool channel only, all five wrapper shapes get stripped + emit a notice.
|
||||
- Broad rewrites of main.rs, the engine, or the TUI app loop.
|
||||
- A `run` shell wrapper. (.env loading is already in-binary via dotenvy.)
|
||||
|
||||
Hard rules / hostility budget:
|
||||
- Prompts must never instruct or imply that the assistant should emit XML/markdown/Replit-style fake tool calls. If you touch crates/tui/src/prompts/*.txt, the rule is: API tool channel only, never wrapped tool calls in assistant text.
|
||||
- Protocol recovery must remain visible. If you change how filter_tool_call_delta or FAKE_WRAPPER_NOTICE works, update crates/tui/tests/protocol_recovery.rs in the same commit and keep at least one assertion that all 5 marker pairs are present.
|
||||
- UI must stay dense and terminal-native. No emoji, no decorative box drawing for data, no gratuitous color. DeepSeek-specific copy (Whale, V4) is welcome.
|
||||
- Show, don't hide, runtime problems. New events should be compact (single-line where possible) and machine-readable as structured fields.
|
||||
|
||||
Stop rules:
|
||||
- Do not revert any user/unrelated work; the previous run already merged and pushed (current main HEAD: d7944421, tag v0.4.6).
|
||||
- Do not push tags or trigger releases. Local commits + push to a feature branch is fine; the maintainer handles release decisions.
|
||||
- Do not delete or modify the existing stash (if any).
|
||||
- If a live provider is down/unpaid, use fixtures/mocks and say so explicitly in the report.
|
||||
- If a gate fails from pre-existing unrelated work, isolate it with evidence — don't paper over.
|
||||
- Pre-existing test cluster (8 tests in tools::git*, tools::diagnostics, tui::ui::tests::workspace_context_refresh*) fails because of a sandbox commit-signing issue with `git commit ... -S`. Document but do not "fix" by globally setting commit.gpgsign=false — fix narrowly inside init_git_repo at crates/tui/src/tui/ui/tests.rs:158 if you touch it.
|
||||
|
||||
Required verification before final report:
|
||||
- cargo fmt --all -- --check
|
||||
- cargo check --workspace --all-targets --locked
|
||||
- cargo test --workspace --all-features --locked
|
||||
- cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
- cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
|
||||
|
||||
Live smoke (optional, time-boxed at 60s):
|
||||
- If DEEPSEEK_API_KEY or NVIDIA_API_KEY is set in the env, run a single bounded turn against the relevant provider with a hard token cap and report the first 30 lines of output. Otherwise skip and say so.
|
||||
|
||||
Final report headings (use exactly these):
|
||||
1. Summary
|
||||
2. Implemented Changes
|
||||
3. GitHub Issues Advanced
|
||||
4. MathCode Reference Points Used (be honest: list which mathcode files you actually read and which patterns you adapted vs deliberately skipped, with one-sentence rationale per skip)
|
||||
5. Tests/Gates
|
||||
6. Live Smoke Result
|
||||
7. Remaining Risks
|
||||
8. Next Best Slice
|
||||
@@ -0,0 +1,44 @@
|
||||
# DeepSeek TUI environment
|
||||
# Shell-exported variables override values in this file.
|
||||
# Copy this file to `.env`, then uncomment only the values you want to use.
|
||||
|
||||
# DeepSeek API (default provider)
|
||||
# Get an API key from DeepSeek, then keep it local in `.env`.
|
||||
# DEEPSEEK_API_KEY=
|
||||
|
||||
# Global endpoint:
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
# China endpoint:
|
||||
# DEEPSEEK_BASE_URL=https://api.deepseeki.com
|
||||
|
||||
# V4 model selection. Compatibility aliases such as `deepseek-chat` normalize
|
||||
# to the current V4 flash model in the TUI.
|
||||
# DEEPSEEK_MODEL=deepseek-v4-pro
|
||||
# DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
|
||||
# NVIDIA NIM-hosted DeepSeek V4
|
||||
# Use this provider when routing through NVIDIA's OpenAI-compatible endpoint.
|
||||
# DEEPSEEK_PROVIDER=nvidia-nim
|
||||
# NVIDIA_API_KEY=
|
||||
# NVIDIA_NIM_API_KEY=
|
||||
# NVIDIA_NIM_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NIM_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
|
||||
|
||||
# Logging
|
||||
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
|
||||
# TUI's lightweight verbose logs for info/debug/trace directives.
|
||||
# DEEPSEEK_LOG_LEVEL=debug
|
||||
# RUST_LOG=deepseek_tui=debug
|
||||
|
||||
# Agent safety defaults
|
||||
# `on-request` asks before higher-risk work; `workspace-write` keeps writes
|
||||
# inside the workspace unless a sandbox elevation path is explicitly used.
|
||||
# DEEPSEEK_APPROVAL_POLICY=on-request
|
||||
# DEEPSEEK_SANDBOX_MODE=workspace-write
|
||||
# DEEPSEEK_ALLOW_SHELL=true
|
||||
|
||||
# Optional extension paths
|
||||
# DEEPSEEK_SKILLS_DIR=~/.deepseek/skills
|
||||
# DEEPSEEK_MCP_CONFIG=~/.deepseek/mcp.json
|
||||
@@ -11,6 +11,18 @@ env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
versions:
|
||||
name: Version drift
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Check version drift
|
||||
run: ./scripts/release/check-versions.sh
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,7 +35,7 @@ jobs:
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features
|
||||
run: cargo clippy --workspace --all-targets --all-features
|
||||
|
||||
test:
|
||||
name: Test
|
||||
@@ -36,9 +48,9 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
run: cargo test --workspace --all-features
|
||||
- name: Run Offline Eval Harness
|
||||
run: cargo run --all-features -- eval
|
||||
run: cargo run -p deepseek-tui --all-features -- eval
|
||||
|
||||
build:
|
||||
name: Build
|
||||
@@ -53,6 +65,51 @@ jobs:
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
npm-wrapper-smoke:
|
||||
name: npm wrapper smoke
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build wrapper binaries
|
||||
run: cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
|
||||
- name: Prepare local release assets
|
||||
run: node scripts/release/prepare-local-release-assets.js "$RUNNER_TEMP/release-assets"
|
||||
- name: Start local release server
|
||||
run: |
|
||||
python3 -m http.server 8123 --directory "$RUNNER_TEMP/release-assets" >/tmp/deepseek-release-server.log 2>&1 &
|
||||
sleep 1
|
||||
- name: Pack npm wrapper
|
||||
working-directory: npm/deepseek-tui
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: npm pack
|
||||
- name: Install packed wrapper
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/npm-smoke"
|
||||
cd "$RUNNER_TEMP/npm-smoke"
|
||||
npm init -y
|
||||
npm install "$GITHUB_WORKSPACE/npm/deepseek-tui"/deepseek-tui-*.tgz
|
||||
- name: Smoke wrapper entrypoints
|
||||
env:
|
||||
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
|
||||
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/npm-smoke"
|
||||
npx --no-install deepseek --help
|
||||
npx --no-install deepseek-tui --help
|
||||
|
||||
# Check documentation builds without warnings
|
||||
docs:
|
||||
name: Documentation
|
||||
@@ -62,6 +119,6 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build docs
|
||||
run: cargo doc --no-deps
|
||||
run: cargo doc --workspace --no-deps
|
||||
env:
|
||||
RUSTDOCFLAGS: -Dwarnings
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish to crates.io
|
||||
name: Publish workspace crates to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -16,15 +16,12 @@ jobs:
|
||||
|
||||
- name: Verify version matches tag
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[0].version')
|
||||
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
|
||||
echo "Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
run: ./scripts/release/verify-workspace-version.sh "${GITHUB_REF#refs/tags/v}"
|
||||
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
- name: Preflight workspace crate publishes in workspace order
|
||||
run: ./scripts/release/publish-crates.sh dry-run
|
||||
|
||||
- name: Publish crates.io packages in workspace order
|
||||
run: ./scripts/release/publish-crates.sh publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
@@ -0,0 +1,58 @@
|
||||
name: parity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Compile check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Unit and parity tests
|
||||
run: cargo test --workspace --all-features --locked
|
||||
|
||||
- name: TUI snapshot parity
|
||||
run: cargo test -p deepseek-tui-core --test snapshot --locked
|
||||
|
||||
- name: Protocol schema sanity
|
||||
run: cargo test -p deepseek-protocol --test parity_protocol --locked
|
||||
|
||||
- name: State persistence sanity
|
||||
run: cargo test -p deepseek-state --test parity_state --locked
|
||||
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Publish npm
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Package/release version to publish, without the leading v'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
# Prefer the npm-only workflow_dispatch path in release.yml. This workflow
|
||||
# works only if npm Trusted Publishing is explicitly configured for
|
||||
# `publish-npm.yml` instead of `release.yml`.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Verify package version
|
||||
working-directory: npm/deepseek-tui
|
||||
run: |
|
||||
actual="$(node -p "require('./package.json').version")"
|
||||
expected="${{ inputs.version }}"
|
||||
if [ "${actual}" != "${expected}" ]; then
|
||||
echo "package.json version ${actual} does not match requested ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Publish wrapper to npm
|
||||
working-directory: npm/deepseek-tui
|
||||
run: npm publish --access public
|
||||
@@ -3,12 +3,50 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Package/release version to publish to npm, without the leading v'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Compile check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
- name: Workspace tests
|
||||
run: cargo test --workspace --all-features --locked
|
||||
- name: TUI snapshot parity
|
||||
run: cargo test -p deepseek-tui-core --test snapshot --locked
|
||||
- name: Protocol schema parity
|
||||
run: cargo test -p deepseek-protocol --test parity_protocol --locked
|
||||
- name: State persistence parity
|
||||
run: cargo test -p deepseek-state --test parity_state --locked
|
||||
- name: Lockfile drift guard
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
|
||||
build:
|
||||
needs: parity
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# --- deepseek (cli) ---
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: deepseek
|
||||
@@ -25,13 +63,30 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: deepseek.exe
|
||||
artifact_name: deepseek-windows-x64.exe
|
||||
# --- deepseek-tui (TUI) ---
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-linux-x64
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-macos-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: deepseek-tui
|
||||
artifact_name: deepseek-tui-macos-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: deepseek-tui.exe
|
||||
artifact_name: deepseek-tui-windows-x64.exe
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- run: cargo build --release --target ${{ matrix.target }}
|
||||
- run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
- name: Rename binary
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -51,7 +106,73 @@ jobs:
|
||||
path: artifacts
|
||||
- name: List artifacts
|
||||
run: find artifacts -type f
|
||||
- name: Generate checksum manifest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p artifacts/checksums
|
||||
manifest="artifacts/checksums/deepseek-artifacts-sha256.txt"
|
||||
: > "${manifest}"
|
||||
while IFS= read -r -d '' file; do
|
||||
hash="$(sha256sum "${file}" | awk '{print $1}')"
|
||||
base="$(basename "${file}")"
|
||||
printf '%s %s\n' "${hash}" "${base}" >> "${manifest}"
|
||||
done < <(find artifacts -type f ! -path 'artifacts/checksums/*' -print0 | sort -z)
|
||||
cat "${manifest}"
|
||||
- uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: artifacts/*/*
|
||||
prerelease: false
|
||||
|
||||
publish-npm:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
# Trusted Publishing via OIDC. Configure npm to trust this workflow
|
||||
# filename (`release.yml`) for Hmbown/DeepSeek-TUI.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Verify package version
|
||||
working-directory: npm/deepseek-tui
|
||||
run: |
|
||||
actual="$(node -p "require('./package.json').version")"
|
||||
expected="${GITHUB_REF_NAME#v}"
|
||||
if [ "${actual}" != "${expected}" ]; then
|
||||
echo "package.json version ${actual} does not match tag ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Publish wrapper to npm
|
||||
working-directory: npm/deepseek-tui
|
||||
run: npm publish --access public
|
||||
|
||||
publish-npm-manual:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
# npm can trust only one workflow filename; keep npm-only retries here so
|
||||
# the trusted publisher can remain `release.yml`.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Verify package version
|
||||
working-directory: npm/deepseek-tui
|
||||
run: |
|
||||
actual="$(node -p "require('./package.json').version")"
|
||||
expected="${{ inputs.version }}"
|
||||
if [ "${actual}" != "${expected}" ]; then
|
||||
echo "package.json version ${actual} does not match requested ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Publish wrapper to npm
|
||||
working-directory: npm/deepseek-tui
|
||||
run: npm publish --access public
|
||||
+6
-8
@@ -11,6 +11,7 @@
|
||||
# Development
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -34,18 +35,16 @@ dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
firebase-debug.log
|
||||
|
||||
# Generated
|
||||
outputs/
|
||||
|
||||
# Rust
|
||||
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
|
||||
firebase-debug.log
|
||||
tmp/
|
||||
|
||||
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
|
||||
|
||||
# Local dev scripts and temp files
|
||||
*.sh
|
||||
!scripts/**
|
||||
test.txt
|
||||
TODO*.md
|
||||
todo*.md
|
||||
@@ -55,14 +54,13 @@ AI_HANDOFF.md
|
||||
result.json
|
||||
count_deps.py
|
||||
project_overhaul_prompt.md
|
||||
|
||||
.codex/
|
||||
docs/rlm-paper.txt
|
||||
.context/
|
||||
|
||||
# Local runtime state
|
||||
.deepseek/
|
||||
session_*.json
|
||||
**/session_*.json
|
||||
*.db
|
||||
|
||||
# Companion app (tracked separately)
|
||||
apps/
|
||||
@@ -0,0 +1,16 @@
|
||||
Hunter Bown <[email protected]> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> GitHub <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.6 <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.6 (1M context) <[email protected]>
|
||||
Hunter Bown <[email protected]> Claude Opus 4.7 (1M context) <[email protected]>
|
||||
Hunter Bown <[email protected]> Copilot <[email protected]>
|
||||
Hunter Bown <[email protected]> Copilot <1693627+github-copilot-cli[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Copilot <946600+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> Qwen-Coder <[email protected]>
|
||||
Hunter Bown <[email protected]> qwencoder <[email protected]>
|
||||
Hunter Bown <[email protected]> Cursor Agent <[email protected]>
|
||||
Hunter Bown <[email protected]> cursoragent <[email protected]>
|
||||
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
|
||||
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
|
||||
@@ -6,71 +6,23 @@ This file provides context for AI assistants working on this project.
|
||||
|
||||
### Commands
|
||||
- Build: `cargo build`
|
||||
- Test: `cargo test`
|
||||
- Run: `cargo run`
|
||||
- Check: `cargo check`
|
||||
- Format: `cargo fmt`
|
||||
- Lint: `cargo clippy`
|
||||
|
||||
### Project: deepseek-tui
|
||||
- Test: `cargo test --workspace --all-features`
|
||||
- Lint: `cargo clippy --workspace --all-targets --all-features`
|
||||
- Format: `cargo fmt --all`
|
||||
- Run: `cargo run -p deepseek-tui`
|
||||
|
||||
### Documentation
|
||||
See README.md for project overview.
|
||||
See README.md for project overview, docs/ARCHITECTURE.md for internals.
|
||||
|
||||
### Version Control
|
||||
This project uses Git. See .gitignore for excluded files.
|
||||
## DeepSeek-Specific Notes
|
||||
|
||||
|
||||
## Advanced Capabilities
|
||||
|
||||
### Model Context Protocol (MCP)
|
||||
This CLI supports MCP for extending tool access.
|
||||
- Use `mcp_read_resource` to read context from external servers.
|
||||
- Use `mcp_get_prompt` to leverage pre-defined expert prompts from servers.
|
||||
- You can connect to HTTP/SSE servers by adding their URL to `mcp.json`.
|
||||
|
||||
### Multi-Agent Orchestration
|
||||
For complex, multi-step tasks, you should delegate work:
|
||||
- **Sub-agents**: Use `agent_spawn` (or its alias `delegate_to_agent`) to launch a background assistant for a specific sub-task. Use `agent_result` to get their output.
|
||||
- **Swarms**: Use `agent_swarm` to orchestrate multiple sub-agents with dependencies. This is ideal for parallel exploration or complex refactoring where different parts of the project can be analyzed concurrently.
|
||||
|
||||
### Project Mapping
|
||||
- Use `project_map` to get a comprehensive view of the codebase structure. This tool respects `.gitignore` and provides a summary of key files.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Proactive Investigation**: Always start by exploring the codebase using `project_map` and `file_search`.
|
||||
- **Parallelism**: When you need to read multiple files or search across different areas, use parallel tool calls if possible.
|
||||
- **Delegation**: If a task is large, break it down into sub-tasks and use `agent_swarm` or `agent_spawn`.
|
||||
- **Testing**: Rigorously verify changes using `cargo test` and `cargo check`.
|
||||
- **Thinking Tokens**: DeepSeek models output thinking blocks (`ContentBlock::Thinking`) before final answers. The TUI streams and displays these with visual distinction.
|
||||
- **Reasoning Models**: `deepseek-v4-pro` and `deepseek-v4-flash` are the documented V4 model IDs. Legacy `deepseek-chat` and `deepseek-reasoner` are compatibility aliases for `deepseek-v4-flash`.
|
||||
- **Large Context Window**: DeepSeek V4 models have 1M-token context windows. Use search tools to navigate efficiently.
|
||||
- **API**: OpenAI-compatible Chat Completions (`/chat/completions`) is the documented DeepSeek API path. Base URL configurable for global (`api.deepseek.com`) or China (`api.deepseeki.com`); `/v1` is accepted for OpenAI SDK compatibility, and `/beta` is only needed for beta features such as strict tool mode, chat prefix completion, and FIM completion.
|
||||
- **Thinking + Tool Calls**: In V4 thinking mode, assistant messages that contain tool calls must replay their `reasoning_content` in all subsequent requests or the API returns HTTP 400.
|
||||
|
||||
## Important Notes
|
||||
|
||||
<!-- Add project-specific notes here -->
|
||||
|
||||
- **Finance tool currently unavailable**: The finance tool relies on Stooq which frequently returns no data. As a workaround, use `web.run` to fetch financial data from web sources.
|
||||
- **Token/cost tracking inaccuracies**: Token counting and cost estimation may be inflated due to thinking token accounting bugs. Use `/compact` to manage context, and treat cost estimates as approximate.
|
||||
- **Web.run tool name**: Note that the tool is named `web.run` (single dot), not `web..run`. Some earlier versions of the CLI may have had this typo.
|
||||
|
||||
### DeepSeek-Specific Capabilities
|
||||
|
||||
This project is built specifically for DeepSeek models, leveraging their unique features:
|
||||
|
||||
**Thinking Tokens**: DeepSeek models can output thinking blocks (`ContentBlock::Thinking`) before providing final answers. The TUI supports streaming and displaying thinking tokens with visual distinction. You can use thinking tokens to reason step-by-step before committing to a response.
|
||||
|
||||
**Reasoning Models**: DeepSeek offers specialized reasoning models (e.g., `deepseek-reasoner`, `deepseek-r1`) that excel at step-by-step problem solving. Consider using these models for complex tasks.
|
||||
|
||||
**Large Context Window**: DeepSeek models have 128k context windows, allowing you to process large codebases. Use `project_map` and `file_search` to navigate efficiently.
|
||||
|
||||
**DeepSeek API**: The CLI uses DeepSeek's OpenAI‑compatible API with support for the Responses API endpoint. The base URL can be configured for global (`api.deepseek.com`) or China (`api.deepseeki.com`).
|
||||
|
||||
**Web Browsing**: For up‑to‑date information about DeepSeek models, documentation, or API changes, use `web.run` with citations. Example search: “DeepSeek API documentation”.
|
||||
|
||||
### Dogfooding Tips
|
||||
|
||||
As a DeepSeek model working on this project, you are “dogfooding” your own tool. Use this opportunity to:
|
||||
- Test the toolset thoroughly and report any issues.
|
||||
- Suggest improvements that would make DeepSeek models more effective.
|
||||
- Keep changes small, focused, and well‑tested.
|
||||
|
||||
Remember to run `cargo test` and `cargo check` after any changes.
|
||||
- **Modes**: Three modes — Plan (read-only investigation), Agent (tool use with approval), YOLO (auto-approved). See `docs/MODES.md` for details.
|
||||
+193
-14
@@ -7,6 +7,183 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.2] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`/model` opens a Pro/Flash + thinking-effort picker (#39).** Typing `/model` with no argument now pops a two-pane modal: model on the left (`deepseek-v4-pro` flagship, `deepseek-v4-flash` fast/cheap, plus a "current (custom)" row when the active id isn't one of the listed defaults), and thinking effort on the right. Tab/←/→ swaps panes, ↑/↓ moves within the focused pane, Enter applies both selections, Esc cancels. The effort pane intentionally exposes only **Off / High / Max** because [DeepSeek's Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model) state `low`/`medium` are mapped to `high` server-side and `xhigh` is mapped to `max` — the legacy variants stay valid in `~/.deepseek/settings.toml` for back-compat, the picker just doesn't surface them. Apply path persists `default_model` and `reasoning_effort` to settings, forwards `Op::SetModel` + `Op::SetCompaction` to the running engine so the next turn picks up the change without a restart, and resets the per-turn token gauges (cache, replay) so the footer numbers reflect the new model. `/model <id>` keeps working unchanged for power users.
|
||||
|
||||
## [0.5.1] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`fetch_url` tool** for direct HTTP GET on a known URL — complements `web_search` for cases where the link is already known. Supports `format` (`markdown` / `text` / `raw`), `max_bytes` (default 1 MB, hard cap 10 MB), `timeout_ms` (default 15 s, max 60 s), redirect following, and structured `{url, status, content_type, content, truncated}` responses. 4xx/5xx bodies are returned (with `success: false`) so the caller can read JSON error envelopes. (#33)
|
||||
- **PDF support in `read_file`.** PDFs are auto-detected by extension or `%PDF-` magic bytes and extracted via `pdftotext -layout` (poppler) when available. New optional `pages` arg (`"5"` or `"1-10"`) reads page slices. Without `pdftotext`, returns a structured `{type: "binary_unavailable", kind: "pdf", reason, hint}` with install commands for macOS/Debian. (#34)
|
||||
- **Reasoning-content replay telemetry, end-to-end (#30).** The chat-completions sanitizer now estimates replayed `reasoning_content` tokens (~4 chars/token), threads the value through the streaming `Usage` payload, stores it on the App, and renders an `rsn N.Nk` chip in the footer next to the cache hit-rate. The chip turns warning-coloured when replay tokens exceed 50% of the input budget, so users on long thinking-mode loops can see at a glance how much of their context window is going to V4's "Interleaved Thinking" replay (paper §5.1.1). Logged at `RUST_LOG=deepseek_tui=info` for tail-friendly diagnosis.
|
||||
- **`@file` Tab-completion (#28).** Typing `@<partial>` and pressing Tab now resolves the mention against the workspace using the existing `ignore::WalkBuilder`. A unique match is spliced into the input; multiple matches with a longer common prefix extend the partial; remaining ambiguity is surfaced via the status line. The mention-expansion path that ships file contents to the model is unchanged — this is purely a discovery aid for typing the path. Inline-contents and a fuzzy popup picker are queued for v0.5.2.
|
||||
- **Per-workspace external trust list (#29).** `~/.deepseek/workspace-trust.json` now records, for each workspace, the absolute paths the user has opted into reading/writing from outside that workspace. The new `/trust` slash command supports `add <path>`, `remove <path>`, `list`, `on`, `off`, and a status read with no args; the engine consults the list when constructing every `ToolContext` so changes apply on the next tool call without restart. `/diagnostics` surfaces the list. The interactive "Allow once / Always allow / Deny" approval prompt is deferred — for now grant access ahead of the turn with `/trust add <path>`.
|
||||
|
||||
### Fixed
|
||||
- **TUI sidebar gutter bleed regression test (#36).** Snapshot tests now lock in that long single-line tool results — including a `todo_write` echo of a multi-kilobyte JSON payload — never write any cells outside `chat_area` at the widths reported in the bug (80, 120, 165, 200 cols). A second test verifies the scrollbar coexists with content along the right edge instead of overdrawing the penultimate column.
|
||||
- **Version drift caught in CI.** New `versions` job in `.github/workflows/ci.yml` runs `scripts/release/check-versions.sh` on every push/PR, verifying every per-crate `Cargo.toml` inherits the workspace version, the npm wrapper matches the workspace version, and `Cargo.lock` is in sync. The release runbook now lists `check-versions.sh` as the first preflight step. (#31)
|
||||
- **Per-mode soft context budget for V4 compaction trigger** (#27).
|
||||
- **Phantom `web.run` references stripped** from prompts and the `web_search` tool surface (#25).
|
||||
- **Unused import + `cargo fmt` drift** that landed with `feat(#27)` and broke Build / Test / npm wrapper smoke under `-Dwarnings`.
|
||||
|
||||
## [0.5.0] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- Multi-turn tool calls on thinking-mode models no longer return HTTP 400. Every assistant message in the conversation now carries `reasoning_content` when thinking is enabled — not just tool-call rounds — matching DeepSeek's actual API validation, which rejects any assistant message missing the field even though the docs describe non-tool-call reasoning as "ignored".
|
||||
- Added a final-pass wire-payload sanitizer in the chat-completions client that forces a non-empty `reasoning_content` placeholder onto any assistant message still missing one at request time. This is the last line of defense after engine-side and build-side substitution, so sessions restored from older checkpoints, sub-agents that append messages directly, and cached prefix mismatches all produce a valid request.
|
||||
- On a `reasoning_content`-related 400, the client now logs the offending message indices to make future regressions diagnosable.
|
||||
- Stripped phantom `web.run` references from prompts and the `web_search` tool surface ([#25](https://github.com/Hmbown/DeepSeek-TUI/issues/25)).
|
||||
|
||||
### Changed
|
||||
- Header/UI widget refactor in the TUI (`crates/tui/src/tui/ui.rs`, `widgets/header.rs`) — internal cleanup, no user-visible behavior change.
|
||||
|
||||
## [0.4.9] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
- DeepSeek thinking-mode tool-call rounds now always replay `reasoning_content` in all subsequent requests (including across new user turns), matching DeepSeek's documented API contract that assistant messages with tool calls must retain their reasoning content forever.
|
||||
- Missing `reasoning_content` on a tool-call assistant message now substitutes a safe placeholder (`"(reasoning omitted)"`) instead of dropping the tool calls and their matching tool results, preventing orphaned conversation chains and API 400 errors.
|
||||
- Session checkpoint now persists a Thinking-block placeholder for tool-call turns that produced no streamed reasoning text, keeping on-disk sessions structurally correct so subsequent requests avoid HTTP 400 rejections.
|
||||
- Token estimation for compaction now counts thinking tokens across all tool-call rounds (not just the current user turn), aligning with the updated reasoning_content replay rule.
|
||||
|
||||
## [0.4.8] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 Pro cost estimates now use DeepSeek's current limited-time 75% discount until 2026-05-05 15:59 UTC, then automatically fall back to the base Pro rates.
|
||||
|
||||
## [0.4.5] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Alternate-screen TUI sessions now capture mouse input by default so wheel scrolling moves the transcript instead of exposing terminal scrollback from before the TUI started. Use `--no-mouse-capture` or `tui.mouse_capture = false` when terminal-native drag selection is preferred.
|
||||
|
||||
## [0.4.2] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 thinking-mode tool turns now checkpoint the engine's authoritative API transcript, including assistant `reasoning_content` on reasoning-to-tool-call turns with no visible assistant text.
|
||||
- Chat Completions request building now drops stale V4 tool-call rounds that are missing required `reasoning_content`, preventing old corrupted checkpoints from triggering DeepSeek HTTP 400 replay errors.
|
||||
- Web search now falls back to Bing HTML results when DuckDuckGo returns a bot challenge or otherwise yields no parseable results.
|
||||
|
||||
## [0.4.1] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 tool-result context now preserves large file reads and command outputs instead of compacting noisy tools to a 900-character snippet after 2k characters.
|
||||
- Capacity guardrail refresh no longer performs destructive summary compaction unless the normal model-aware compaction thresholds are actually crossed.
|
||||
- V4 compaction summaries retain larger tool-result excerpts and summary input when compaction is genuinely needed.
|
||||
- The transcript now follows the bottom again when sending a new message, shows an in-app scrollbar when internally scrolled, and leaves mouse capture off in `--no-alt-screen` mode so terminal-native scrolling can work.
|
||||
|
||||
## [0.4.0] - 2026-04-23
|
||||
|
||||
### Added
|
||||
- **DeepSeek V4 support**: `deepseek-v4-pro` (flagship) and `deepseek-v4-flash` (fast/cheap) are now first-class model IDs with 1M context windows.
|
||||
- **Reasoning-effort tier**: new `reasoning_effort` config field (`off | low | medium | high | max`) mapped to DeepSeek's `reasoning_effort` + `thinking` request fields. Defaults to `max`.
|
||||
- **Shift+Tab cycles reasoning-effort** through the three behaviorally distinct tiers (`off → high → max`). The current tier is shown as a ⚡ chip in the header.
|
||||
- Per-model pricing table: `deepseek-v4-pro` priced at $0.145/$1.74/$3.48 per 1M tokens (cache-hit/miss/output); `deepseek-v4-flash` and legacy aliases at $0.028/$0.14/$0.28.
|
||||
|
||||
### Changed
|
||||
- **Default model flipped to `deepseek-v4-pro`** (from `deepseek-reasoner`).
|
||||
- `deepseek-chat` / `deepseek-reasoner` remain as silent aliases of `deepseek-v4-flash` for API compatibility; priced identically.
|
||||
- **Context compaction**: 1M-context V4 models now compact at 800k input tokens or 2,000 messages, so short/tool-heavy sessions do not compact as if they were 128k-context runs.
|
||||
- Cycling modes is now Tab-only; Shift+Tab is repurposed for reasoning-effort (reverse-mode cycle was low-value with only three modes).
|
||||
- Updated help/hint strings, validator error messages, and the model picker to reference V4 IDs.
|
||||
|
||||
### Fixed
|
||||
- `requires_reasoning_content` now recognizes `deepseek-v4*` so thinking streams render correctly on V4 models.
|
||||
- DeepSeek V4 thinking-mode tool calls now preserve prior assistant `reasoning_content` whenever a tool call is replayed, matching DeepSeek's multi-turn contract and avoiding HTTP 400 rejections on later turns.
|
||||
- Raw Chat Completions requests now send DeepSeek's top-level `thinking` parameter instead of the OpenAI SDK-only `extra_body` wrapper.
|
||||
- Config, env, and UI model selection now normalize legacy DeepSeek aliases to `deepseek-v4-flash` instead of preserving old model labels.
|
||||
- npm wrapper first-run downloads now use process-unique temp files so concurrent `deepseek` / `deepseek-tui` invocations do not race on `*.download` files.
|
||||
|
||||
## [0.3.33] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Footer polish: simplified footer rendering, removed footer clock label, updated status line layout
|
||||
- Palette cleanup: removed `FOOTER_HINT` color constant
|
||||
|
||||
### Removed
|
||||
- `FOOTER_HINT` color constant from palette (use `TEXT_MUTED` or `TEXT_HINT` instead)
|
||||
|
||||
### Fixed
|
||||
- Test updates to align with simplified footer logic
|
||||
- Empty state placeholder text removed for cleaner UI
|
||||
|
||||
## [0.3.32] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- Finance tool: Yahoo Finance v8 quote endpoint with chart fallback, supporting stocks, ETFs, indices, forex, and crypto lookups.
|
||||
- Header widget redesign: proportional truncation, context-usage bar with gradient fill, streaming indicator, and graceful narrow-terminal degradation.
|
||||
- Expanded test coverage: 680+ tests including footer state, context spans, plan prompt lifecycle, workspace context refresh, header rendering, and finance tool integration tests with wiremock.
|
||||
- Workspace context refresh with configurable TTL and deferred initial fetch.
|
||||
- Config command additions for runtime settings management.
|
||||
|
||||
### Changed
|
||||
- Redesigned footer status strip with mode/model/status layout, context bar, and narrow-terminal fallback.
|
||||
- Plan prompt now uses numeric selection (1-4) instead of keyword input; old aliases are sent as regular messages.
|
||||
- Archived outdated docs (`workspace_migration_status.md` -> `docs/archive/`).
|
||||
- Trimmed AGENTS.md boilerplate and updated task counts.
|
||||
- Clarified release-surface documentation: crates.io publication may lag the workspace/npm wrapper.
|
||||
|
||||
### Fixed
|
||||
- Header `metadata_spans` now uses `saturating_sub` to prevent underflow on narrow terminals.
|
||||
- Finance tool reuses a single HTTP client instead of rebuilding per request.
|
||||
- Finance tool tests no longer leak temp directories.
|
||||
|
||||
## [0.3.31] - 2026-03-08
|
||||
|
||||
### Added
|
||||
- Replaced the finance tool backend with Yahoo Finance v8 + CoinGecko fallback for reliable real-time market data (stocks, ETFs, indices, forex, crypto).
|
||||
- Added compaction UX: status strip shows animated COMPACTING indicator during context summarization, footer reflects compaction state, and CompactionCompleted events now include message count statistics.
|
||||
- Added send flash: brief tinted background highlight on the last user message after sending.
|
||||
- Added braille typing indicator with smooth 10-frame animation cycle.
|
||||
|
||||
### Changed
|
||||
- Redesigned the footer status strip with mode/model/token/cost layout, quadrant separators, and a context-usage bar.
|
||||
- Added Unicode prefix indicators (▸ You, ◆ Answer, ● System) to chat history cells for visual distinction.
|
||||
- Improved thinking token delineation with labeled delimiters in transcript rendering.
|
||||
- Refactored source code into workspace crates for better modularity and dependency management.
|
||||
|
||||
### Fixed
|
||||
- Fixed Plan mode ESC key dismissing the prompt without clearing `plan_prompt_pending`, which prevented the prompt from reappearing on subsequent plan completions.
|
||||
- Fixed clippy lint (collapsible_if) in web browsing session management.
|
||||
|
||||
## [0.3.30] - 2026-03-06
|
||||
|
||||
### Added
|
||||
- Added a release-ready local npm smoke path that builds binaries, serves release assets locally, packs the wrapper, installs the tarball, and checks both entrypoints before publish.
|
||||
- Added an opt-in full-matrix local release-asset fixture so `npm run release:check` can be exercised before GitHub release assets exist.
|
||||
|
||||
### Changed
|
||||
- Bumped the Rust workspace crates and npm wrapper to `0.3.30`.
|
||||
- Pointed the npm wrapper's default `deepseekBinaryVersion` at `0.3.30` for the next coordinated Rust + npm release.
|
||||
- Updated the crates dry-run helper to work from a dirty workspace and to preflight dependent workspace crates without requiring unpublished versions to already exist on crates.io.
|
||||
|
||||
## [0.3.29] - 2026-03-03
|
||||
|
||||
### Added
|
||||
- Added npm publish-time release asset verification for the `deepseek-tui` package to fail fast when expected GitHub binaries are missing.
|
||||
- Added checksum manifests to GitHub release assets and checksum verification in the npm installer.
|
||||
- Added `npm pack` install-and-smoke CI coverage for the `deepseek-tui` wrapper package.
|
||||
- Added an end-to-end release runbook covering crates.io, GitHub Releases, and npm publication.
|
||||
|
||||
### Changed
|
||||
- Updated npm package documentation for clearer install modes, environment overrides, and release integrity behavior.
|
||||
- Improved installer support-matrix error messaging for unsupported platform/architecture combinations.
|
||||
- Decoupled npm package version from default binary artifact version via `deepseekBinaryVersion`, enabling packaging-only npm releases.
|
||||
- Moved the `deepseek-tui` binary target inside `crates/tui` so `cargo publish --dry-run -p deepseek-tui` works from the workspace package layout.
|
||||
- Replaced the root-level crates publish workflow with an ordered workspace publish flow.
|
||||
- Reworked first-run onboarding and README copy around primary workflows instead of shortcut memorization.
|
||||
- Relaxed onboarding API-key format heuristics so unusual keys warn instead of blocking setup.
|
||||
|
||||
## [0.3.28] - 2026-03-02
|
||||
|
||||
### Added
|
||||
- Converted the project to a modular Cargo workspace using a `crates/` layout.
|
||||
- Added new crate boundaries mirroring a deepseek architecture (`agent`, `config`, `core`, `execpolicy`, `hooks`, `mcp`, `protocol`, `state`, `tools`, `tui-core`, `tui`, and `app-server`).
|
||||
|
||||
### Changed
|
||||
- Added parity CI coverage with protocol/state/snapshot checks.
|
||||
- Updated release workflow to build both `deepseek` and `deepseek-tui` binaries.
|
||||
|
||||
## [0.3.26] - 2026-03-02
|
||||
|
||||
### Fixed
|
||||
@@ -104,7 +281,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
- Expanded reasoning-model detection for chat history reconstruction (supports R-series and reasoner-style naming without hardcoding single versions).
|
||||
- Aligned docs/config examples with actual runtime default model (`deepseek-v3.2`).
|
||||
- Aligned docs/config examples with the then-current runtime default model.
|
||||
|
||||
## [0.3.14] - 2026-02-05
|
||||
|
||||
@@ -342,7 +519,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.0] - 2026-01-12
|
||||
|
||||
### Added
|
||||
- Initial alpha release of DeepSeek CLI
|
||||
- Initial alpha release of DeepSeek TUI
|
||||
- Interactive TUI chat interface
|
||||
- DeepSeek API integration (OpenAI-compatible Responses API)
|
||||
- Tool execution (shell, file ops)
|
||||
@@ -353,7 +530,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.23...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.9...HEAD
|
||||
[0.4.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.8...v0.4.9
|
||||
[0.4.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.33...v0.4.8
|
||||
[0.3.33]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.32...v0.3.33
|
||||
[0.3.32]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...v0.3.32
|
||||
[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.31
|
||||
[0.3.28]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.27...v0.3.28
|
||||
[0.3.23]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...v0.3.23
|
||||
[0.3.22]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...v0.3.22
|
||||
[0.3.21]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.17...v0.3.21
|
||||
@@ -364,9 +547,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
[0.3.12]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.11...v0.3.12
|
||||
[0.3.11]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.10...v0.3.11
|
||||
[0.3.10]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.3...v0.3.4
|
||||
@@ -375,13 +555,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
[0.3.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.2...v0.3.0
|
||||
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
|
||||
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
|
||||
[0.2.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.2.0
|
||||
[0.0.2]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.2
|
||||
[0.0.1]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.0.1
|
||||
[0.1.9]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.8...v0.1.9
|
||||
[0.1.8]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.7...v0.1.8
|
||||
[0.1.7]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.6...v0.1.7
|
||||
[0.1.6]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.5...v0.1.6
|
||||
[0.1.5]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.0...v0.1.5
|
||||
[0.1.0]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.1.0
|
||||
[0.0.1]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.1
|
||||
[0.1.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.8...v0.1.9
|
||||
[0.1.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.7...v0.1.8
|
||||
[0.1.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.6...v0.1.7
|
||||
[0.1.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.5...v0.1.6
|
||||
[0.1.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.0...v0.1.5
|
||||
[0.1.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.1.0
|
||||
+33
-31
@@ -1,6 +1,6 @@
|
||||
# Contributing to DeepSeek CLI
|
||||
# Contributing to DeepSeek TUI
|
||||
|
||||
Thank you for your interest in contributing to DeepSeek CLI! This document provides guidelines and instructions for contributing.
|
||||
Thank you for your interest in contributing to DeepSeek TUI! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -14,8 +14,8 @@ Thank you for your interest in contributing to DeepSeek CLI! This document provi
|
||||
|
||||
1. Fork and clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/DeepSeek-CLI.git
|
||||
cd DeepSeek-CLI
|
||||
git clone https://github.com/YOUR_USERNAME/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
```
|
||||
|
||||
2. Build the project:
|
||||
@@ -45,8 +45,11 @@ Thank you for your interest in contributing to DeepSeek CLI! This document provi
|
||||
### Testing
|
||||
|
||||
- Write tests for new functionality
|
||||
- Ensure all existing tests pass: `cargo test`
|
||||
- For integration tests, use the `tests/` directory
|
||||
- Ensure all existing tests pass: `cargo test --workspace --all-features`
|
||||
- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]`
|
||||
modules), and add integration tests under the owning crate's `tests/`
|
||||
directory (for example `crates/tui/tests/` or `crates/state/tests/`). The
|
||||
repository root `tests/` directory is not used
|
||||
|
||||
### Commit Messages
|
||||
|
||||
@@ -59,35 +62,34 @@ Use clear, descriptive commit messages following conventional commits:
|
||||
- `test:` Adding or updating tests
|
||||
- `chore:` Maintenance tasks
|
||||
|
||||
Example: `feat: add --doctor command for system diagnostics`
|
||||
Example: `feat: add doctor subcommand for system diagnostics`
|
||||
|
||||
## Project Structure
|
||||
|
||||
DeepSeek TUI is a Cargo workspace. The live runtime and the majority of TUI,
|
||||
engine, and tool code currently live in `crates/tui/src/`. Smaller workspace
|
||||
crates provide shared abstractions that are being extracted incrementally.
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry point and CLI definition
|
||||
├── config.rs # Configuration management
|
||||
├── client.rs # HTTP client for DeepSeek API
|
||||
├── llm_client.rs # LLM abstraction layer
|
||||
├── models.rs # Data structures
|
||||
├── mcp.rs # Model Context Protocol support
|
||||
├── hooks.rs # Hook system for extensibility
|
||||
├── skills.rs # Skills/plugin system
|
||||
├── core/ # Core engine components
|
||||
│ ├── engine.rs # Main agent loop
|
||||
│ ├── session.rs # Session management
|
||||
│ └── ...
|
||||
├── tools/ # Built-in tools
|
||||
│ ├── shell.rs # Shell execution
|
||||
│ ├── file.rs # File operations
|
||||
│ └── ...
|
||||
├── tui/ # Terminal UI
|
||||
│ ├── app.rs # Application state
|
||||
│ ├── ui.rs # Rendering logic
|
||||
│ └── ...
|
||||
└── sandbox/ # Sandbox execution (macOS)
|
||||
crates/
|
||||
├── tui/ deepseek-tui binary (interactive TUI + runtime API)
|
||||
├── cli/ deepseek binary (dispatcher facade)
|
||||
├── app-server/ HTTP/SSE + JSON-RPC transport
|
||||
├── core/ Agent loop / session / turn management
|
||||
├── protocol/ Request/response framing
|
||||
├── config/ Config loading, profiles, env precedence
|
||||
├── state/ SQLite thread/session persistence
|
||||
├── tools/ Typed tool specs and lifecycle
|
||||
├── mcp/ MCP client + stdio server
|
||||
├── hooks/ Lifecycle hooks (stdout/jsonl/webhook)
|
||||
├── execpolicy/ Approval/sandbox policy engine
|
||||
├── agent/ Model/provider registry
|
||||
└── tui-core/ Event-driven TUI state machine scaffold
|
||||
```
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the live data flow across
|
||||
these crates and [DEPENDENCY_GRAPH.md](DEPENDENCY_GRAPH.md) for build ordering.
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
1. Create a feature branch from `main`:
|
||||
@@ -121,7 +123,7 @@ When reporting issues, please include:
|
||||
|
||||
- Operating system and version
|
||||
- Rust version (`rustc --version`)
|
||||
- DeepSeek CLI version (`deepseek --version`)
|
||||
- DeepSeek TUI version (`deepseek --version`)
|
||||
- Steps to reproduce the issue
|
||||
- Expected vs actual behavior
|
||||
- Relevant error messages or logs
|
||||
@@ -132,7 +134,7 @@ Be respectful and inclusive. We welcome contributors of all backgrounds and expe
|
||||
|
||||
## License
|
||||
|
||||
By contributing to DeepSeek CLI, you agree that your contributions will be licensed under the MIT License.
|
||||
By contributing to DeepSeek TUI, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
Generated
+554
-6
@@ -242,6 +242,28 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
@@ -404,9 +426,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -427,9 +457,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.42"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -503,6 +533,15 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmp_any"
|
||||
version = "0.8.1"
|
||||
@@ -524,6 +563,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
@@ -570,6 +619,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -745,9 +804,136 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"deepseek-agent",
|
||||
"deepseek-config",
|
||||
"deepseek-core",
|
||||
"deepseek-execpolicy",
|
||||
"deepseek-hooks",
|
||||
"deepseek-mcp",
|
||||
"deepseek-protocol",
|
||||
"deepseek-state",
|
||||
"deepseek-tools",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"deepseek-agent",
|
||||
"deepseek-config",
|
||||
"deepseek-execpolicy",
|
||||
"deepseek-hooks",
|
||||
"deepseek-mcp",
|
||||
"deepseek-protocol",
|
||||
"deepseek-state",
|
||||
"deepseek-tools",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"deepseek-protocol",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"deepseek-protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.26"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -799,6 +985,28 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-cli"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"deepseek-agent",
|
||||
"deepseek-app-server",
|
||||
"deepseek-config",
|
||||
"deepseek-execpolicy",
|
||||
"deepseek-mcp",
|
||||
"deepseek-state",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.5.2"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -942,6 +1150,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dupe"
|
||||
version = "0.9.1"
|
||||
@@ -1038,6 +1252,18 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1153,6 +1379,12 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -1278,8 +1510,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1289,9 +1523,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1364,6 +1600,15 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -1797,6 +2042,38 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.83"
|
||||
@@ -1860,6 +2137,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -1943,6 +2231,12 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.94.1"
|
||||
@@ -2065,10 +2359,10 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -2297,6 +2591,12 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
@@ -2469,6 +2769,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2509,6 +2818,62 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases 0.2.1",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.43"
|
||||
@@ -2534,6 +2899,35 @@ dependencies = [
|
||||
"nibble_vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.7.1"
|
||||
@@ -2672,12 +3066,16 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2703,6 +3101,26 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
@@ -2735,6 +3153,7 @@ version = "0.23.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
@@ -2742,21 +3161,62 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 3.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -2891,7 +3351,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -3960,6 +4433,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -4146,6 +4628,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -4182,6 +4673,21 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -4215,6 +4721,12 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -4227,6 +4739,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4239,6 +4757,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -4263,6 +4787,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4275,6 +4805,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -4287,6 +4823,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -4299,6 +4841,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
||||
+30
-60
@@ -1,73 +1,43 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.26"
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/agent",
|
||||
"crates/app-server",
|
||||
"crates/cli",
|
||||
"crates/config",
|
||||
"crates/core",
|
||||
"crates/execpolicy",
|
||||
"crates/hooks",
|
||||
"crates/mcp",
|
||||
"crates/protocol",
|
||||
"crates/state",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
"crates/tui-core",
|
||||
]
|
||||
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.2"
|
||||
edition = "2024"
|
||||
description = "Terminal-native TUI and CLI for DeepSeek models"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
keywords = ["deepseek", "cli", "ai", "agent", "llm"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
base64 = "0.22.1"
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.4", features = ["json"] }
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
colored = "3.0.0"
|
||||
crossterm = "0.28"
|
||||
csv = "1.4"
|
||||
dotenvy = "0.15.7"
|
||||
dirs = "6.0.0"
|
||||
futures-util = "0.3.31"
|
||||
indicatif = "0.18.0"
|
||||
ratatui = "0.29"
|
||||
regex = "1.11"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
|
||||
rustyline = "15.0.0"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls"] }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
shellexpand = "3"
|
||||
toml = "0.9.7"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
unicode-width = "0.2"
|
||||
unicode-segmentation = "1.12"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
tokio-stream = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
toml = "0.9.7"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
starlark = "0.13.0"
|
||||
tiny_http = "0.12"
|
||||
portable-pty = "0.8"
|
||||
zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
pdf-extract = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
# Platform-specific dependencies
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.60", features = ["Win32_Foundation"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
@@ -0,0 +1,65 @@
|
||||
# Dependency Graph
|
||||
|
||||
## Crate Dependencies (from Cargo.toml)
|
||||
|
||||
```
|
||||
deepseek-tui (binary: `deepseek-tui`)
|
||||
(no workspace deps — monolith source under crates/tui/src/)
|
||||
|
||||
deepseek-tui-cli (binary: `deepseek`)
|
||||
<- deepseek-agent
|
||||
<- deepseek-app-server
|
||||
<- deepseek-config
|
||||
<- deepseek-execpolicy
|
||||
<- deepseek-mcp
|
||||
<- deepseek-state
|
||||
|
||||
deepseek-app-server
|
||||
<- deepseek-agent
|
||||
<- deepseek-config
|
||||
<- deepseek-core
|
||||
<- deepseek-execpolicy
|
||||
<- deepseek-hooks
|
||||
<- deepseek-mcp
|
||||
<- deepseek-protocol
|
||||
<- deepseek-state
|
||||
<- deepseek-tools
|
||||
|
||||
deepseek-core (agent loop)
|
||||
<- deepseek-agent
|
||||
<- deepseek-config
|
||||
<- deepseek-execpolicy
|
||||
<- deepseek-hooks
|
||||
<- deepseek-mcp
|
||||
<- deepseek-protocol
|
||||
<- deepseek-state
|
||||
<- deepseek-tools
|
||||
|
||||
deepseek-tools <- deepseek-protocol
|
||||
deepseek-mcp <- deepseek-protocol
|
||||
deepseek-hooks <- deepseek-protocol
|
||||
deepseek-execpolicy <- deepseek-protocol
|
||||
deepseek-agent <- deepseek-config
|
||||
|
||||
deepseek-config (leaf — no internal deps)
|
||||
deepseek-protocol (leaf — no internal deps)
|
||||
deepseek-state (leaf — no internal deps)
|
||||
deepseek-tui-core (leaf — no internal deps)
|
||||
```
|
||||
|
||||
Note: `deepseek-tui` has zero workspace deps because it still compiles the
|
||||
monolith source tree (`crates/tui/src/main.rs`). The crate split is
|
||||
structural — source migration into individual workspace crates is
|
||||
incremental.
|
||||
|
||||
## Build Order (bottom-up)
|
||||
|
||||
```
|
||||
Layer 0 (leaves): deepseek-protocol, deepseek-config, deepseek-state, deepseek-tui-core
|
||||
Layer 1: deepseek-tools, deepseek-mcp, deepseek-hooks, deepseek-execpolicy
|
||||
Layer 2: deepseek-agent
|
||||
Layer 3: deepseek-core
|
||||
Layer 4: deepseek-app-server, deepseek-tui
|
||||
Layer 5: deepseek-tui-cli
|
||||
```
|
||||
|
||||
@@ -1,90 +1,194 @@
|
||||
# DeepSeek TUI
|
||||
|
||||
A terminal-native TUI and CLI for [DeepSeek](https://platform.deepseek.com) models, built in Rust.
|
||||
> **A terminal-native coding agent for [DeepSeek V4](https://platform.deepseek.com) models — with 1M-token context, thinking-mode reasoning, and full tool-use.**
|
||||
|
||||
```bash
|
||||
npm i -g deepseek-tui
|
||||
```
|
||||
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/deepseek-tui)
|
||||
[](https://www.npmjs.com/package/deepseek-tui)
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hero.png" alt="DeepSeek CLI" width="800">
|
||||
</p>
|
||||

|
||||
|
||||
For DeepSeek models (current and future model IDs). Not affiliated with DeepSeek Inc.
|
||||
---
|
||||
|
||||
## What is this
|
||||
## What is it?
|
||||
|
||||
A terminal-native agent loop that gives DeepSeek the tools it needs to actually write code: file editing, shell execution, web search, git operations, task tracking, and MCP server integration. Coherence-aware memory compaction keeps long sessions on track without blowing up the context window.
|
||||
DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives DeepSeek's frontier models direct access to your workspace — reading and editing files, running shell commands, searching the web, managing git, and orchestrating sub-agents — all through a fast, keyboard-driven TUI.
|
||||
|
||||
Three modes:
|
||||
**Built for DeepSeek V4** (`deepseek-v4-pro` / `deepseek-v4-flash`) with 1M-token context windows and native thinking-mode (chain-of-thought) streaming. See the model's reasoning unfold in real time as it works through your tasks.
|
||||
|
||||
- **Plan** — design-first, proposes before acting
|
||||
- **Agent** — multi-step autonomous tool use
|
||||
- **YOLO** — full auto-approve, no guardrails (preloads tools by default)
|
||||
### Key Features
|
||||
|
||||
**Recent highlights**: sub‑agent orchestration (background workers, parallel tool calls, dependency‑aware swarms), parallel tool execution (`multi_tool_use.parallel`), runtime HTTP/SSE API (`deepseek serve --http`), background task queue (`/task`), interactive configuration (`/config`), model discovery (`/models`), command palette (`Ctrl+K`), expandable tool payloads (`v`), persistent sidebar for live plan/todo/sub‑agent state, and model context‑window suffix hints (`-32k`, `-256k`).
|
||||
- 🧠 **Thinking-mode streaming** — watch DeepSeek's chain-of-thought as it reasons about your code
|
||||
- 🔧 **Full tool suite** — file ops, shell execution, git, web search/browse, apply-patch, sub-agents, MCP servers, and more
|
||||
- 🪟 **1M-token context** — feed entire codebases; automatic intelligent compaction when context fills up
|
||||
- 🎛️ **Three interaction modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved)
|
||||
- ⚡ **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab
|
||||
- 🔄 **Session save/resume** — checkpoint and resume long sessions, fork conversations
|
||||
- 🌐 **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows
|
||||
- 📦 **MCP protocol** — connect to Model Context Protocol servers for extended tooling
|
||||
- 💰 **Live cost tracking** — per-turn and session-level token usage and cost estimates
|
||||
- 🎨 **Dark & light themes** — with a DeepSeek-blue branded palette
|
||||
|
||||
## Install
|
||||
---
|
||||
|
||||
```bash
|
||||
# From crates.io (requires Rust 1.85+)
|
||||
cargo install deepseek-tui --locked
|
||||
|
||||
# Or from source
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI && cargo install --path . --locked
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Create `~/.deepseek/config.toml`:
|
||||
|
||||
```toml
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY"
|
||||
```
|
||||
|
||||
Then run:
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm install -g deepseek-tui
|
||||
deepseek
|
||||
```
|
||||
|
||||
**Tab** switches modes, **F1** opens help, **Esc** cancels a running request.
|
||||
On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). You can also set it ahead of time:
|
||||
|
||||
```bash
|
||||
# via CLI
|
||||
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
|
||||
|
||||
# via env var
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
deepseek
|
||||
```
|
||||
|
||||
### Using NVIDIA NIM
|
||||
|
||||
```bash
|
||||
deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
|
||||
deepseek --provider nvidia-nim
|
||||
|
||||
# or per-process:
|
||||
DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Install from source</summary>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
cargo install --path crates/tui --locked # requires Rust 1.85+
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## What's new in v0.5.0
|
||||
|
||||
- **Multi-turn tool calls no longer 400 on thinking-mode models.** Every assistant message now replays `reasoning_content` (with a safe placeholder when the round produced none), and a final-pass sanitizer guarantees the wire payload satisfies DeepSeek's thinking-mode contract — even for sessions restored from older checkpoints or sub-agents that bypass the engine path.
|
||||
- **Phantom `web.run` references stripped** from prompts and the `web_search` tool ([#25](https://github.com/Hmbown/DeepSeek-TUI/issues/25)).
|
||||
|
||||
Full history: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Models & Pricing
|
||||
|
||||
DeepSeek TUI targets **DeepSeek V4** models with 1M-token context windows by default.
|
||||
|
||||
| Model | Context | Input (cache hit) | Input (cache miss) | Output |
|
||||
|---|---|---|---|---|
|
||||
| `deepseek-v4-pro` | 1M | $0.03625 / 1M* | $0.435 / 1M* | $0.87 / 1M* |
|
||||
| `deepseek-v4-flash` | 1M | $0.028 / 1M | $0.14 / 1M | $0.28 / 1M |
|
||||
|
||||
Legacy aliases `deepseek-chat` and `deepseek-reasoner` silently map to `deepseek-v4-flash`.
|
||||
|
||||
**NVIDIA NIM** hosted variants (`deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash`) use your NVIDIA account terms — no DeepSeek platform billing.
|
||||
|
||||
*\*DeepSeek lists the Pro rates above as a limited-time 75% discount valid until 2026-05-05 15:59 UTC; the TUI cost estimator falls back to base Pro rates after that timestamp.*
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
deepseek # interactive TUI
|
||||
deepseek -p "explain this in 2 sentences" # one-shot prompt
|
||||
deepseek --yolo # agent mode, all tools auto-approved
|
||||
deepseek doctor # check your setup
|
||||
deepseek models # list available models
|
||||
deepseek serve --http # start HTTP/SSE API server
|
||||
deepseek # interactive TUI
|
||||
deepseek "explain this function" # one-shot prompt
|
||||
deepseek --model deepseek-v4-flash "summarize" # model override
|
||||
deepseek --yolo # YOLO mode (auto-approve tools)
|
||||
deepseek login --api-key "..." # save API key
|
||||
deepseek doctor # check setup & connectivity
|
||||
deepseek models # list live API models
|
||||
deepseek sessions # list saved sessions
|
||||
deepseek resume --last # resume latest session
|
||||
deepseek serve --http # HTTP/SSE API server
|
||||
```
|
||||
|
||||
Within the TUI, use `/config`, `/models`, `/task`, and `Ctrl+K` command palette.
|
||||
### Keyboard shortcuts
|
||||
|
||||
## Model IDs
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Cycle mode: Plan → Agent → YOLO |
|
||||
| `Shift+Tab` | Cycle reasoning-effort: off → high → max |
|
||||
| `F1` | Help |
|
||||
| `Esc` | Back / dismiss |
|
||||
| `Ctrl+K` | Command palette |
|
||||
| `@path` | Attach file/directory context in composer |
|
||||
| `/attach <path>` | Attach image/video media references |
|
||||
|
||||
Common model IDs: `deepseek-chat`, `deepseek-reasoner`.
|
||||
---
|
||||
|
||||
Any valid `deepseek-*` model ID is accepted (including future releases). Model IDs can include context‑window suffix hints (`-32k`, `-256k`). To see live IDs from your configured endpoint:
|
||||
## Modes
|
||||
|
||||
```bash
|
||||
deepseek models
|
||||
```
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| **Plan** 🔍 | Read-only investigation — model explores and proposes a plan before making changes |
|
||||
| **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates |
|
||||
| **YOLO** ⚡ | Auto-approve all tools in a trusted workspace (use with caution) |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything lives in `~/.deepseek/config.toml`. See [config.example.toml](config.example.toml) for the full set of options.
|
||||
`~/.deepseek/config.toml` — see [config.example.toml](config.example.toml) for every option.
|
||||
|
||||
Common environment overrides: `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`, `DEEPSEEK_CONFIG_PATH`, `DEEPSEEK_PROFILE`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_TRUST_MODE`, and `DEEPSEEK_CAPACITY_*`.
|
||||
Key environment overrides:
|
||||
|
||||
For the full config/env matrix (profiles, feature flags, capacity tuning, sandbox controls), see [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DEEPSEEK_API_KEY` | API key |
|
||||
| `DEEPSEEK_BASE_URL` | API base URL |
|
||||
| `DEEPSEEK_MODEL` | Default model |
|
||||
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default) or `nvidia-nim` |
|
||||
| `DEEPSEEK_PROFILE` | Config profile name |
|
||||
| `NVIDIA_API_KEY` | NVIDIA NIM API key |
|
||||
|
||||
## Docs
|
||||
Quick diagnostics:
|
||||
|
||||
Detailed docs are in the [docs/](docs/) folder — architecture, modes, MCP integration, runtime API, etc.
|
||||
```bash
|
||||
deepseek-tui setup --status # read-only status check (API key, MCP, sandbox, .env)
|
||||
deepseek-tui doctor --json # machine-readable doctor output for CI
|
||||
deepseek-tui setup --tools --plugins # scaffold tools/ and plugins/ directories
|
||||
```
|
||||
|
||||
DeepSeek context caching is automatic — when the API returns cache hit/miss token fields, the TUI includes them in usage and cost tracking.
|
||||
|
||||
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Topic |
|
||||
|---|---|
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Codebase internals |
|
||||
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference |
|
||||
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
|
||||
| [MCP.md](docs/MCP.md) | Model Context Protocol integration |
|
||||
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server |
|
||||
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process |
|
||||
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome!
|
||||
|
||||
*Not affiliated with DeepSeek Inc.*
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[MIT](LICENSE)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 755 KiB |
+64
-16
@@ -1,5 +1,5 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
# ║ DeepSeek CLI Configuration ║
|
||||
# ║ DeepSeek TUI Configuration ║
|
||||
# ║ ║
|
||||
# ║ Unofficial CLI for DeepSeek Platform - Not affiliated with DeepSeek Inc. ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -7,20 +7,42 @@
|
||||
# See `docs/CONFIGURATION.md` for how config is loaded (profiles, env overrides, etc.).
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# API Keys
|
||||
# Active provider + DeepSeek defaults
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Choose which provider to use by default. Per-provider credentials live in the
|
||||
# `[providers.deepseek]` and `[providers.nvidia_nim]` sections near the bottom of
|
||||
# this file — keeping both stored at once means `/provider deepseek` and
|
||||
# `/provider nvidia-nim` (or `--provider nvidia-nim`) 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 | nvidia-nim
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Base URLs
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
base_url = "https://api.deepseek.com"
|
||||
# base_url = "https://api.deepseeki.com" # China users
|
||||
# base_url = "https://api.deepseek.com/beta" # DeepSeek beta features such as strict tool mode
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Default Models
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
default_text_model = "deepseek-reasoner" # common IDs: deepseek-reasoner, deepseek-chat (any deepseek-* ID is valid)
|
||||
# DeepSeek V4 family:
|
||||
# deepseek-v4-pro — flagship reasoning model on DeepSeek Platform
|
||||
# deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner)
|
||||
# deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID
|
||||
# deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID
|
||||
default_text_model = "deepseek-v4-pro"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Thinking Mode (DeepSeek V4 reasoning effort)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# "off" — disables chain-of-thought (thinking.type = disabled)
|
||||
# "low" — compat-maps to "high" server-side
|
||||
# "medium" — compat-maps to "high" server-side
|
||||
# "high" — reasoning_effort = high (DeepSeek default)
|
||||
# "max" — reasoning_effort = max (deepest reasoning)
|
||||
#
|
||||
# Shift+Tab in the TUI cycles between off / high / max. The header shows the
|
||||
# current tier as a ⚡ chip.
|
||||
reasoning_effort = "max"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Paths
|
||||
@@ -46,11 +68,34 @@ max_subagents = 5 # optional (1-20)
|
||||
# managed_config_path = "/etc/deepseek/managed_config.toml"
|
||||
# requirements_path = "/etc/deepseek/requirements.toml"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Per-provider credentials (peer providers — NIM is first-class, not a flag)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Both providers can be stored at once; `provider = "..."` (top of file) or
|
||||
# `/provider deepseek` / `/provider nvidia-nim` switches between them without
|
||||
# having to re-enter keys. Env vars override anything set here:
|
||||
# DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
|
||||
# NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL
|
||||
# (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL
|
||||
|
||||
# DeepSeek Platform (https://platform.deepseek.com)
|
||||
[providers.deepseek]
|
||||
# api_key = "YOUR_DEEPSEEK_API_KEY"
|
||||
# base_url = "https://api.deepseek.com"
|
||||
# model = "deepseek-v4-pro"
|
||||
|
||||
# NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com)
|
||||
[providers.nvidia_nim]
|
||||
# api_key = "YOUR_NVIDIA_API_KEY"
|
||||
# base_url = "https://integrate.api.nvidia.com/v1"
|
||||
# model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# TUI
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
[tui]
|
||||
alternate_screen = "auto" # auto | always | never
|
||||
mouse_capture = true # true keeps wheel scrolling inside the TUI; false allows terminal-native drag selection/copy
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Feature Flags
|
||||
@@ -58,7 +103,7 @@ alternate_screen = "auto" # auto | always | never
|
||||
[features]
|
||||
shell_tool = true
|
||||
subagents = true
|
||||
web_search = true # enables web.run and web_search
|
||||
web_search = true # enables canonical web.run plus the compatibility web_search alias
|
||||
apply_patch = true
|
||||
mcp = true
|
||||
exec_policy = true
|
||||
@@ -74,14 +119,11 @@ max_delay = 60.0
|
||||
exponential_base = 2.0
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Context Compaction (config-level tuning not yet wired; use /set auto_compact on|off)
|
||||
# Context Compaction
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# [compaction]
|
||||
# enabled = false # Enable auto-compaction
|
||||
# token_threshold = 50000 # Trigger compaction above this token estimate
|
||||
# message_threshold = 50 # Or above this message count
|
||||
# model = "deepseek-chat" # Model to use for summarization
|
||||
# cache_summary = true # Cache the summary block
|
||||
# Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`).
|
||||
# There is no config-file `[compaction]` table yet; detailed thresholds are
|
||||
# chosen by the TUI from the active model/context budget.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Capacity Controller (runtime pressure guardrails)
|
||||
@@ -113,6 +155,12 @@ base_url = "https://api.deepseek.com"
|
||||
api_key = "DEV_DEEPSEEK_API_KEY"
|
||||
allow_shell = true
|
||||
|
||||
[profiles.nvidia-nim]
|
||||
provider = "nvidia-nim"
|
||||
api_key = "YOUR_NVIDIA_API_KEY"
|
||||
base_url = "https://integrate.api.nvidia.com/v1"
|
||||
default_text_model = "deepseek-ai/deepseek-v4-pro"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Hooks (optional)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -125,7 +173,7 @@ allow_shell = true
|
||||
#
|
||||
# [[hooks.hooks]]
|
||||
# event = "session_start"
|
||||
# command = "echo 'DeepSeek CLI session started'"
|
||||
# command = "echo 'DeepSeek TUI session started'"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Requirements (admin constraints) example file
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "deepseek-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deepseek_config::ProviderKind;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub provider: ProviderKind,
|
||||
pub aliases: Vec<String>,
|
||||
pub supports_tools: bool,
|
||||
pub supports_reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelResolution {
|
||||
pub requested: Option<String>,
|
||||
pub resolved: ModelInfo,
|
||||
pub used_fallback: bool,
|
||||
pub fallback_chain: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelRegistry {
|
||||
models: Vec<ModelInfo>,
|
||||
alias_map: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Default for ModelRegistry {
|
||||
fn default() -> Self {
|
||||
let models = vec![
|
||||
ModelInfo {
|
||||
id: "deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: vec![],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: vec![
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
"deepseek-r1".to_string(),
|
||||
"deepseek-v3".to_string(),
|
||||
"deepseek-v3.2".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-ai/deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
aliases: vec![
|
||||
"deepseek-v4-pro".to_string(),
|
||||
"nvidia-deepseek-v4-pro".to_string(),
|
||||
"nim-deepseek-v4-pro".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-ai/deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
aliases: vec![
|
||||
"deepseek-v4-flash".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
"nvidia-deepseek-v4-flash".to_string(),
|
||||
"nim-deepseek-v4-flash".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1-mini".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt-4o-mini".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
];
|
||||
Self::new(models)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelRegistry {
|
||||
#[must_use]
|
||||
pub fn new(models: Vec<ModelInfo>) -> Self {
|
||||
let mut alias_map = HashMap::new();
|
||||
for (idx, model) in models.iter().enumerate() {
|
||||
alias_map.entry(normalize(&model.id)).or_insert(idx);
|
||||
for alias in &model.aliases {
|
||||
alias_map.entry(normalize(alias)).or_insert(idx);
|
||||
}
|
||||
}
|
||||
Self { models, alias_map }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<ModelInfo> {
|
||||
self.models.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
&self,
|
||||
requested: Option<&str>,
|
||||
provider_hint: Option<ProviderKind>,
|
||||
) -> ModelResolution {
|
||||
let mut fallback_chain = Vec::new();
|
||||
|
||||
if let Some(name) = requested {
|
||||
fallback_chain.push(format!("requested:{name}"));
|
||||
if let Some(provider) = provider_hint
|
||||
&& let Some(model) = self
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.provider == provider && model_matches(m, name))
|
||||
.cloned()
|
||||
{
|
||||
return ModelResolution {
|
||||
requested: Some(name.to_string()),
|
||||
resolved: model,
|
||||
used_fallback: false,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
if let Some(idx) = self.alias_map.get(&normalize(name)) {
|
||||
return ModelResolution {
|
||||
requested: Some(name.to_string()),
|
||||
resolved: self.models[*idx].clone(),
|
||||
used_fallback: false,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
|
||||
fallback_chain.push(format!("provider_default:{}", provider.as_str()));
|
||||
if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
|
||||
return ModelResolution {
|
||||
requested: requested.map(ToOwned::to_owned),
|
||||
resolved: model,
|
||||
used_fallback: true,
|
||||
fallback_chain,
|
||||
};
|
||||
}
|
||||
|
||||
let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
|
||||
id: "deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Deepseek,
|
||||
aliases: Vec::new(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
});
|
||||
fallback_chain.push("global_default:deepseek-v4-pro".to_string());
|
||||
ModelResolution {
|
||||
requested: requested.map(ToOwned::to_owned),
|
||||
resolved: final_fallback,
|
||||
used_fallback: true,
|
||||
fallback_chain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn model_matches(model: &ModelInfo, requested: &str) -> bool {
|
||||
let requested = normalize(requested);
|
||||
normalize(&model.id) == requested
|
||||
|| model
|
||||
.aliases
|
||||
.iter()
|
||||
.any(|alias| normalize(alias) == requested)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_default_uses_catalog_model_id() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "deepseek-app-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style app-server transport for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-core = { path = "../core", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
deepseek-tools = { path = "../tools", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -0,0 +1,783 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::State;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use deepseek_agent::ModelRegistry;
|
||||
use deepseek_config::{CliRuntimeOverrides, ConfigStore};
|
||||
use deepseek_core::Runtime;
|
||||
use deepseek_execpolicy::ExecPolicyEngine;
|
||||
use deepseek_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink};
|
||||
use deepseek_mcp::McpManager;
|
||||
use deepseek_protocol::{
|
||||
AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadRequest, ThreadResponse,
|
||||
};
|
||||
use deepseek_state::StateStore;
|
||||
use deepseek_tools::{ToolCall, ToolRegistry};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppServerOptions {
|
||||
pub listen: SocketAddr,
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config_path: Option<PathBuf>,
|
||||
config: Arc<RwLock<deepseek_config::ConfigToml>>,
|
||||
runtime: Arc<Mutex<Runtime>>,
|
||||
registry: ModelRegistry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ToolCallRequest {
|
||||
call: ToolCall,
|
||||
#[serde(default)]
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[serde(default)]
|
||||
jsonrpc: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StdioDispatchResult {
|
||||
result: Value,
|
||||
should_exit: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigGetParams {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigSetParams {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThreadIdParams {
|
||||
thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThreadMessageParams {
|
||||
thread_id: String,
|
||||
input: String,
|
||||
}
|
||||
|
||||
pub async fn run(options: AppServerOptions) -> Result<()> {
|
||||
let state = build_state(options.config_path.clone())?;
|
||||
|
||||
let app = Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/thread", post(thread_handler))
|
||||
.route("/app", post(app_handler))
|
||||
.route("/prompt", post(prompt_handler))
|
||||
.route("/tool", post(tool_handler))
|
||||
.route("/jobs", get(jobs_handler))
|
||||
.route("/mcp/startup", post(mcp_startup_handler))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(options.listen).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
let state = build_state(config_path)?;
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
let mut reader = BufReader::new(stdin).lines();
|
||||
let mut writer = tokio::io::BufWriter::new(stdout);
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let response = jsonrpc_error(
|
||||
None,
|
||||
JsonRpcError::parse_error(format!("invalid json: {err}")),
|
||||
);
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if request
|
||||
.jsonrpc
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != "2.0")
|
||||
{
|
||||
let response = jsonrpc_error(
|
||||
request.id,
|
||||
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
|
||||
);
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match dispatch_stdio_request(&state, &request.method, request.params).await {
|
||||
Ok(dispatch) => {
|
||||
let encoded = jsonrpc_result(request.id, dispatch.result);
|
||||
writer.write_all(encoded.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
if dispatch.should_exit {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(err) => jsonrpc_error(request.id, err),
|
||||
};
|
||||
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn healthz() -> Json<Value> {
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"protocol": "v2",
|
||||
"service": "deepseek-app-server"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn thread_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ThreadRequest>,
|
||||
) -> Json<ThreadResponse> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
match runtime.handle_thread(req).await {
|
||||
Ok(res) => Json(res),
|
||||
Err(err) => Json(ThreadResponse {
|
||||
thread_id: "error".to_string(),
|
||||
status: format!("error:{err}"),
|
||||
thread: None,
|
||||
threads: Vec::new(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
sandbox: None,
|
||||
events: Vec::new(),
|
||||
data: json!({}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<PromptRequest>,
|
||||
) -> Json<PromptResponse> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
let overrides = CliRuntimeOverrides::default();
|
||||
match runtime.handle_prompt(req, &overrides).await {
|
||||
Ok(res) => Json(res),
|
||||
Err(err) => Json(PromptResponse {
|
||||
output: err.to_string(),
|
||||
model: "unknown".to_string(),
|
||||
events: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ToolCallRequest>,
|
||||
) -> Json<Value> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
let cwd = req
|
||||
.cwd
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
match runtime
|
||||
.invoke_tool(
|
||||
req.call,
|
||||
deepseek_execpolicy::AskForApproval::OnRequest,
|
||||
&cwd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => Json(value),
|
||||
Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
|
||||
}
|
||||
}
|
||||
|
||||
async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
Json(runtime.app_status())
|
||||
}
|
||||
|
||||
async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
|
||||
let runtime = state.runtime.lock().await;
|
||||
let summary = runtime.mcp_startup().await;
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"summary": summary
|
||||
}))
|
||||
}
|
||||
|
||||
async fn app_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AppRequest>,
|
||||
) -> Json<AppResponse> {
|
||||
Json(process_app_request(&state, req).await)
|
||||
}
|
||||
|
||||
fn build_state(config_path: Option<PathBuf>) -> Result<AppState> {
|
||||
let store = ConfigStore::load(config_path.clone())?;
|
||||
let config = store.config.clone();
|
||||
let registry = ModelRegistry::default();
|
||||
|
||||
let state_db_path = config_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|parent| parent.join("state.db")));
|
||||
let state_store = StateStore::open(state_db_path)?;
|
||||
|
||||
let mut hooks = HookDispatcher::default();
|
||||
hooks.add_sink(Arc::new(StdoutHookSink));
|
||||
let hook_log_path = config_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
|
||||
.unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
|
||||
hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
|
||||
|
||||
let runtime = Runtime::new(
|
||||
config.clone(),
|
||||
registry.clone(),
|
||||
state_store,
|
||||
Arc::new(ToolRegistry::default()),
|
||||
Arc::new(McpManager::default()),
|
||||
ExecPolicyEngine::new(Vec::new(), Vec::new()),
|
||||
hooks,
|
||||
);
|
||||
|
||||
Ok(AppState {
|
||||
config_path,
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
runtime: Arc::new(Mutex::new(runtime)),
|
||||
registry,
|
||||
})
|
||||
}
|
||||
|
||||
fn params_or_object(params: Value) -> Value {
|
||||
if params.is_null() { json!({}) } else { params }
|
||||
}
|
||||
|
||||
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
|
||||
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
|
||||
}
|
||||
|
||||
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"result": result
|
||||
})
|
||||
}
|
||||
|
||||
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"error": {
|
||||
"code": err.code,
|
||||
"message": err.message,
|
||||
"data": err.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl JsonRpcError {
|
||||
fn parse_error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32700,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32600,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn method_not_found(method: &str) -> Self {
|
||||
Self {
|
||||
code: -32601,
|
||||
message: format!("unsupported method: {method}"),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_params(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32602,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32603,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_thread_request(
|
||||
state: &AppState,
|
||||
req: ThreadRequest,
|
||||
) -> std::result::Result<ThreadResponse, JsonRpcError> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
runtime
|
||||
.handle_thread(req)
|
||||
.await
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))
|
||||
}
|
||||
|
||||
async fn handle_prompt_request(
|
||||
state: &AppState,
|
||||
req: PromptRequest,
|
||||
) -> std::result::Result<PromptResponse, JsonRpcError> {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
runtime
|
||||
.handle_prompt(req, &CliRuntimeOverrides::default())
|
||||
.await
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))
|
||||
}
|
||||
|
||||
async fn dispatch_stdio_request(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
|
||||
let outcome = match method {
|
||||
"healthz" | "app/healthz" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"status": "ok",
|
||||
"service": "deepseek-app-server",
|
||||
"transport": "stdio"
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"transport": "stdio",
|
||||
"families": ["thread/*", "app/*", "prompt/*"],
|
||||
"methods": [
|
||||
"healthz",
|
||||
"thread/capabilities",
|
||||
"thread/request",
|
||||
"thread/create",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
"thread/fork",
|
||||
"thread/list",
|
||||
"thread/read",
|
||||
"thread/set_name",
|
||||
"thread/archive",
|
||||
"thread/unarchive",
|
||||
"thread/message",
|
||||
"app/capabilities",
|
||||
"app/request",
|
||||
"app/config/get",
|
||||
"app/config/set",
|
||||
"app/config/unset",
|
||||
"app/config/list",
|
||||
"app/models",
|
||||
"app/thread_loaded_list",
|
||||
"prompt/capabilities",
|
||||
"prompt/request",
|
||||
"prompt/run",
|
||||
"shutdown"
|
||||
]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"thread/capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"methods": [
|
||||
"thread/request",
|
||||
"thread/create",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
"thread/fork",
|
||||
"thread/list",
|
||||
"thread/read",
|
||||
"thread/set_name",
|
||||
"thread/archive",
|
||||
"thread/unarchive",
|
||||
"thread/message"
|
||||
]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"thread/request" => {
|
||||
let request: ThreadRequest = parse_params(params)?;
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/create" => {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateParams {
|
||||
#[serde(default)]
|
||||
metadata: Value,
|
||||
}
|
||||
let parsed: CreateParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Create {
|
||||
metadata: parsed.metadata,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/start" => {
|
||||
let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/resume" => {
|
||||
let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/fork" => {
|
||||
let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/list" => {
|
||||
let request = ThreadRequest::List(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/read" => {
|
||||
let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/set_name" | "thread/set-name" => {
|
||||
let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
|
||||
let response = handle_thread_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/archive" => {
|
||||
let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Archive {
|
||||
thread_id: parsed.thread_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/unarchive" => {
|
||||
let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Unarchive {
|
||||
thread_id: parsed.thread_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"thread/message" => {
|
||||
let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
|
||||
let response = handle_thread_request(
|
||||
state,
|
||||
ThreadRequest::Message {
|
||||
thread_id: parsed.thread_id,
|
||||
input: parsed.input,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/capabilities" => {
|
||||
let response = process_app_request(state, AppRequest::Capabilities).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/request" => {
|
||||
let request: AppRequest = parse_params(params)?;
|
||||
let response = process_app_request(state, request).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/get" => {
|
||||
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/set" => {
|
||||
let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
|
||||
let response = process_app_request(
|
||||
state,
|
||||
AppRequest::ConfigSet {
|
||||
key: parsed.key,
|
||||
value: parsed.value,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/unset" => {
|
||||
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/config/list" => {
|
||||
let response = process_app_request(state, AppRequest::ConfigList).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/models" => {
|
||||
let response = process_app_request(state, AppRequest::Models).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"app/thread_loaded_list" | "app/thread-loaded-list" => {
|
||||
let response = process_app_request(state, AppRequest::ThreadLoadedList).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"prompt/capabilities" => StdioDispatchResult {
|
||||
result: json!({
|
||||
"methods": ["prompt/request", "prompt/run"]
|
||||
}),
|
||||
should_exit: false,
|
||||
},
|
||||
"prompt/request" | "prompt/run" => {
|
||||
let request: PromptRequest = parse_params(params)?;
|
||||
let response = handle_prompt_request(state, request).await?;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
"shutdown" => StdioDispatchResult {
|
||||
result: json!({"ok": true, "status": "stopped"}),
|
||||
should_exit: true,
|
||||
},
|
||||
_ => return Err(JsonRpcError::method_not_found(method)),
|
||||
};
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse {
|
||||
match req {
|
||||
AppRequest::Capabilities => AppResponse {
|
||||
ok: true,
|
||||
data: json!({
|
||||
"routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
|
||||
"config": ["get", "set", "unset", "list"],
|
||||
"events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
|
||||
"transport": "stdio+http",
|
||||
"config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
|
||||
}),
|
||||
events: Vec::new(),
|
||||
},
|
||||
AppRequest::ConfigGet { key } => {
|
||||
let cfg = state.config.read().await;
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "key": key, "value": cfg.get_value(&key) }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigSet { key, value } => {
|
||||
let mut cfg = state.config.write().await;
|
||||
let result = cfg.set_value(&key, &value);
|
||||
let ok = result.is_ok();
|
||||
let message = result.err().map(|e| e.to_string());
|
||||
let snapshot = cfg.clone();
|
||||
drop(cfg);
|
||||
let _ = persist_config(state, snapshot).await;
|
||||
AppResponse {
|
||||
ok,
|
||||
data: json!({ "key": key, "value": value, "error": message }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigUnset { key } => {
|
||||
let mut cfg = state.config.write().await;
|
||||
let result = cfg.unset_value(&key);
|
||||
let ok = result.is_ok();
|
||||
let message = result.err().map(|e| e.to_string());
|
||||
let snapshot = cfg.clone();
|
||||
drop(cfg);
|
||||
let _ = persist_config(state, snapshot).await;
|
||||
AppResponse {
|
||||
ok,
|
||||
data: json!({ "key": key, "error": message }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigList => {
|
||||
let cfg = state.config.read().await;
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "values": cfg.list_values() }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::Models => AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "models": state.registry.list() }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
AppRequest::ThreadLoadedList => {
|
||||
let mut runtime = state.runtime.lock().await;
|
||||
let response = runtime
|
||||
.handle_thread(deepseek_protocol::ThreadRequest::List(
|
||||
deepseek_protocol::ThreadListParams {
|
||||
include_archived: false,
|
||||
limit: Some(50),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
match response {
|
||||
Ok(thread_resp) => AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "threads": thread_resp.threads }),
|
||||
events: thread_resp.events,
|
||||
},
|
||||
Err(err) => AppResponse {
|
||||
ok: false,
|
||||
data: json!({ "error": err.to_string() }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_config(state: &AppState, config: deepseek_config::ConfigToml) -> Result<()> {
|
||||
if state.config_path.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut store = ConfigStore::load(state.config_path.clone())?;
|
||||
store.config = config;
|
||||
store.save()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use deepseek_app_server::{AppServerOptions, run};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "deepseek-app-server",
|
||||
about = "Run the DeepSeek app-server transport"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
#[arg(long, default_value_t = 8787)]
|
||||
port: u16,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let listen: SocketAddr = format!("{}:{}", cli.host, cli.port)
|
||||
.parse()
|
||||
.with_context(|| format!("invalid listen address {}:{}", cli.host, cli.port))?;
|
||||
run(AppServerOptions {
|
||||
listen,
|
||||
config_path: cli.config,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "deepseek-tui-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style CLI facade for DeepSeek workspace architecture"
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
chrono.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "deepseek-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Config schema and precedence model for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
@@ -0,0 +1,840 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CONFIG_FILE_NAME: &str = "config.toml";
|
||||
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
|
||||
const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
|
||||
const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
|
||||
const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1";
|
||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
|
||||
const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProviderKind {
|
||||
#[default]
|
||||
Deepseek,
|
||||
NvidiaNim,
|
||||
Openai,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Deepseek => "deepseek",
|
||||
Self::NvidiaNim => "nvidia-nim",
|
||||
Self::Openai => "openai",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek" | "deep-seek" => Some(Self::Deepseek),
|
||||
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
|
||||
"openai" | "open-ai" => Some(Self::Openai),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderConfigToml {
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProvidersToml {
|
||||
#[serde(default)]
|
||||
pub deepseek: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub nvidia_nim: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub openai: ProviderConfigToml,
|
||||
}
|
||||
|
||||
impl ProvidersToml {
|
||||
#[must_use]
|
||||
pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &self.deepseek,
|
||||
ProviderKind::NvidiaNim => &self.nvidia_nim,
|
||||
ProviderKind::Openai => &self.openai,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &mut self.deepseek,
|
||||
ProviderKind::NvidiaNim => &mut self.nvidia_nim,
|
||||
ProviderKind::Openai => &mut self.openai,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ConfigToml {
|
||||
/// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
|
||||
/// and `deepseek-tui` can share a single config file.
|
||||
pub api_key: Option<String>,
|
||||
/// TUI-compatible DeepSeek base URL.
|
||||
pub base_url: Option<String>,
|
||||
/// TUI-compatible default DeepSeek model.
|
||||
pub default_text_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider: ProviderKind,
|
||||
pub model: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub chatgpt_access_token: Option<String>,
|
||||
pub device_code_session: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: Option<bool>,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub providers: ProvidersToml,
|
||||
#[serde(flatten)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
#[must_use]
|
||||
pub fn get_value(&self, key: &str) -> Option<String> {
|
||||
match key {
|
||||
"provider" => Some(self.provider.as_str().to_string()),
|
||||
"api_key" => self.api_key.clone(),
|
||||
"base_url" => self.base_url.clone(),
|
||||
"default_text_model" => self.default_text_model.clone(),
|
||||
"model" => self.model.clone(),
|
||||
"auth.mode" => self.auth_mode.clone(),
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token.clone(),
|
||||
"auth.device_code_session" => self.device_code_session.clone(),
|
||||
"output_mode" => self.output_mode.clone(),
|
||||
"log_level" => self.log_level.clone(),
|
||||
"telemetry" => self.telemetry.map(|v| v.to_string()),
|
||||
"approval_policy" => self.approval_policy.clone(),
|
||||
"sandbox_mode" => self.sandbox_mode.clone(),
|
||||
"providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
|
||||
"providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
|
||||
"providers.deepseek.model" => self.providers.deepseek.model.clone(),
|
||||
"providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(),
|
||||
"providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(),
|
||||
"providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(),
|
||||
"providers.openai.api_key" => self.providers.openai.api_key.clone(),
|
||||
"providers.openai.base_url" => self.providers.openai.base_url.clone(),
|
||||
"providers.openai.model" => self.providers.openai.model.clone(),
|
||||
_ => self.extras.get(key).map(toml::Value::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
|
||||
match key {
|
||||
"provider" => {
|
||||
self.provider = ProviderKind::parse(value)
|
||||
.with_context(|| format!("unknown provider '{value}'"))?;
|
||||
}
|
||||
"api_key" => self.api_key = Some(value.to_string()),
|
||||
"base_url" => self.base_url = Some(value.to_string()),
|
||||
"default_text_model" => self.default_text_model = Some(value.to_string()),
|
||||
"model" => self.model = Some(value.to_string()),
|
||||
"auth.mode" => self.auth_mode = Some(value.to_string()),
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token = Some(value.to_string()),
|
||||
"auth.device_code_session" => self.device_code_session = Some(value.to_string()),
|
||||
"output_mode" => self.output_mode = Some(value.to_string()),
|
||||
"log_level" => self.log_level = Some(value.to_string()),
|
||||
"telemetry" => {
|
||||
self.telemetry = Some(parse_bool(value)?);
|
||||
}
|
||||
"approval_policy" => self.approval_policy = Some(value.to_string()),
|
||||
"sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
|
||||
"providers.deepseek.api_key" => {
|
||||
let value = value.to_string();
|
||||
self.providers.deepseek.api_key = Some(value.clone());
|
||||
self.api_key = Some(value);
|
||||
}
|
||||
"providers.deepseek.base_url" => {
|
||||
let value = value.to_string();
|
||||
self.providers.deepseek.base_url = Some(value.clone());
|
||||
self.base_url = Some(value);
|
||||
}
|
||||
"providers.deepseek.model" => {
|
||||
let value = value.to_string();
|
||||
self.providers.deepseek.model = Some(value.clone());
|
||||
self.default_text_model = Some(value);
|
||||
}
|
||||
"providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
|
||||
"providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
|
||||
"providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
|
||||
"providers.nvidia_nim.api_key" => {
|
||||
self.providers.nvidia_nim.api_key = Some(value.to_string());
|
||||
}
|
||||
"providers.nvidia_nim.base_url" => {
|
||||
self.providers.nvidia_nim.base_url = Some(value.to_string());
|
||||
}
|
||||
"providers.nvidia_nim.model" => {
|
||||
self.providers.nvidia_nim.model = Some(value.to_string());
|
||||
}
|
||||
_ => {
|
||||
self.extras
|
||||
.insert(key.to_string(), toml::Value::String(value.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unset_value(&mut self, key: &str) -> Result<()> {
|
||||
match key {
|
||||
"provider" => self.provider = ProviderKind::Deepseek,
|
||||
"api_key" => self.api_key = None,
|
||||
"base_url" => self.base_url = None,
|
||||
"default_text_model" => self.default_text_model = None,
|
||||
"model" => self.model = None,
|
||||
"auth.mode" => self.auth_mode = None,
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token = None,
|
||||
"auth.device_code_session" => self.device_code_session = None,
|
||||
"output_mode" => self.output_mode = None,
|
||||
"log_level" => self.log_level = None,
|
||||
"telemetry" => self.telemetry = None,
|
||||
"approval_policy" => self.approval_policy = None,
|
||||
"sandbox_mode" => self.sandbox_mode = None,
|
||||
"providers.deepseek.api_key" => {
|
||||
self.providers.deepseek.api_key = None;
|
||||
self.api_key = None;
|
||||
}
|
||||
"providers.deepseek.base_url" => {
|
||||
self.providers.deepseek.base_url = None;
|
||||
self.base_url = None;
|
||||
}
|
||||
"providers.deepseek.model" => {
|
||||
self.providers.deepseek.model = None;
|
||||
self.default_text_model = None;
|
||||
}
|
||||
"providers.openai.api_key" => self.providers.openai.api_key = None,
|
||||
"providers.openai.base_url" => self.providers.openai.base_url = None,
|
||||
"providers.openai.model" => self.providers.openai.model = None,
|
||||
"providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None,
|
||||
"providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None,
|
||||
"providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None,
|
||||
_ => {
|
||||
self.extras.remove(key);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list_values(&self) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
out.insert("provider".to_string(), self.provider.as_str().to_string());
|
||||
|
||||
if let Some(v) = self.api_key.as_ref() {
|
||||
out.insert("api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.base_url.as_ref() {
|
||||
out.insert("base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.default_text_model.as_ref() {
|
||||
out.insert("default_text_model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.model.as_ref() {
|
||||
out.insert("model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.auth_mode.as_ref() {
|
||||
out.insert("auth.mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.chatgpt_access_token.as_ref() {
|
||||
out.insert("auth.chatgpt_access_token".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.device_code_session.as_ref() {
|
||||
out.insert("auth.device_code_session".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.output_mode.as_ref() {
|
||||
out.insert("output_mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.log_level.as_ref() {
|
||||
out.insert("log_level".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.telemetry {
|
||||
out.insert("telemetry".to_string(), v.to_string());
|
||||
}
|
||||
if let Some(v) = self.approval_policy.as_ref() {
|
||||
out.insert("approval_policy".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.sandbox_mode.as_ref() {
|
||||
out.insert("sandbox_mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.api_key.as_ref() {
|
||||
out.insert("providers.deepseek.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.base_url.as_ref() {
|
||||
out.insert("providers.deepseek.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.deepseek.model.as_ref() {
|
||||
out.insert("providers.deepseek.model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.openai.api_key.as_ref() {
|
||||
out.insert("providers.openai.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.openai.base_url.as_ref() {
|
||||
out.insert("providers.openai.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.openai.model.as_ref() {
|
||||
out.insert("providers.openai.model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.nvidia_nim.api_key.as_ref() {
|
||||
out.insert("providers.nvidia_nim.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.providers.nvidia_nim.base_url.as_ref() {
|
||||
out.insert("providers.nvidia_nim.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.nvidia_nim.model.as_ref() {
|
||||
out.insert("providers.nvidia_nim.model".to_string(), v.clone());
|
||||
}
|
||||
|
||||
for (k, v) in &self.extras {
|
||||
out.insert(k.clone(), v.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
|
||||
let env = EnvRuntimeOverrides::load();
|
||||
let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
|
||||
|
||||
let provider_cfg = self.providers.for_provider(provider);
|
||||
let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
|
||||
.then(|| self.api_key.clone())
|
||||
.flatten();
|
||||
let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
|
||||
.then(|| self.base_url.clone())
|
||||
.flatten();
|
||||
let root_deepseek_model = (provider == ProviderKind::Deepseek)
|
||||
.then(|| self.default_text_model.clone())
|
||||
.flatten();
|
||||
let api_key = cli
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| env.api_key_for(provider))
|
||||
.or_else(|| provider_cfg.api_key.clone())
|
||||
.or(root_deepseek_api_key);
|
||||
|
||||
let base_url = cli
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| env.base_url_for(provider))
|
||||
.or_else(|| provider_cfg.base_url.clone())
|
||||
.or(root_deepseek_base_url)
|
||||
.unwrap_or_else(|| match provider {
|
||||
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
|
||||
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
|
||||
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
|
||||
});
|
||||
|
||||
let model = cli
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| env.model.clone())
|
||||
.or_else(|| provider_cfg.model.clone())
|
||||
.or(root_deepseek_model)
|
||||
.or_else(|| self.model.clone())
|
||||
.unwrap_or_else(|| match provider {
|
||||
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL.to_string(),
|
||||
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
|
||||
ProviderKind::Openai => DEFAULT_OPENAI_MODEL.to_string(),
|
||||
});
|
||||
let model = normalize_model_for_provider(provider, &model);
|
||||
|
||||
let output_mode = cli
|
||||
.output_mode
|
||||
.clone()
|
||||
.or_else(|| env.output_mode.clone())
|
||||
.or_else(|| self.output_mode.clone());
|
||||
let auth_mode = cli
|
||||
.auth_mode
|
||||
.clone()
|
||||
.or_else(|| env.auth_mode.clone())
|
||||
.or_else(|| self.auth_mode.clone());
|
||||
let log_level = cli
|
||||
.log_level
|
||||
.clone()
|
||||
.or_else(|| env.log_level.clone())
|
||||
.or_else(|| self.log_level.clone());
|
||||
let telemetry = cli
|
||||
.telemetry
|
||||
.or(env.telemetry)
|
||||
.or(self.telemetry)
|
||||
.unwrap_or(false);
|
||||
let approval_policy = cli
|
||||
.approval_policy
|
||||
.clone()
|
||||
.or_else(|| env.approval_policy.clone())
|
||||
.or_else(|| self.approval_policy.clone());
|
||||
let sandbox_mode = cli
|
||||
.sandbox_mode
|
||||
.clone()
|
||||
.or_else(|| env.sandbox_mode.clone())
|
||||
.or_else(|| self.sandbox_mode.clone());
|
||||
|
||||
ResolvedRuntimeOptions {
|
||||
provider,
|
||||
model,
|
||||
api_key,
|
||||
base_url,
|
||||
auth_mode,
|
||||
output_mode,
|
||||
log_level,
|
||||
telemetry,
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
|
||||
let normalized = model.trim().to_ascii_lowercase();
|
||||
match (provider, normalized.as_str()) {
|
||||
(ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
|
||||
DEFAULT_NVIDIA_NIM_MODEL.to_string()
|
||||
}
|
||||
(
|
||||
ProviderKind::NvidiaNim,
|
||||
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
|
||||
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
|
||||
) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
|
||||
_ => model.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CliRuntimeOverrides {
|
||||
pub provider: Option<ProviderKind>,
|
||||
pub model: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: Option<bool>,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedRuntimeOptions {
|
||||
pub provider: ProviderKind,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: String,
|
||||
pub auth_mode: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: bool,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
pub config: ConfigToml,
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
pub fn load(path: Option<PathBuf>) -> Result<Self> {
|
||||
let path = resolve_config_path(path)?;
|
||||
if !path.exists() {
|
||||
return Ok(Self {
|
||||
path,
|
||||
config: ConfigToml::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read config at {}", path.display()))?;
|
||||
let parsed: ConfigToml = toml::from_str(&raw)
|
||||
.with_context(|| format!("failed to parse config at {}", path.display()))?;
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
config: parsed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create config directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let body = toml::to_string_pretty(&self.config).context("failed to serialize config")?;
|
||||
fs::write(&self.path, body)
|
||||
.with_context(|| format!("failed to write config at {}", self.path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(path) = explicit {
|
||||
return Ok(path);
|
||||
}
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
default_config_path()
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("failed to resolve home directory for config path")?;
|
||||
Ok(home.join(".deepseek").join(CONFIG_FILE_NAME))
|
||||
}
|
||||
|
||||
fn parse_bool(raw: &str) -> Result<bool> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" | "enabled" => Ok(true),
|
||||
"0" | "false" | "no" | "off" | "disabled" => Ok(false),
|
||||
_ => bail!("invalid boolean '{raw}'"),
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_secret(secret: &str) -> String {
|
||||
if secret.len() <= 8 {
|
||||
return "********".to_string();
|
||||
}
|
||||
format!("{}***{}", &secret[..4], &secret[secret.len() - 4..])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct EnvRuntimeOverrides {
|
||||
provider: Option<ProviderKind>,
|
||||
model: Option<String>,
|
||||
output_mode: Option<String>,
|
||||
auth_mode: Option<String>,
|
||||
log_level: Option<String>,
|
||||
telemetry: Option<bool>,
|
||||
approval_policy: Option<String>,
|
||||
sandbox_mode: Option<String>,
|
||||
deepseek_api_key: Option<String>,
|
||||
openai_api_key: Option<String>,
|
||||
nvidia_api_key: Option<String>,
|
||||
deepseek_base_url: Option<String>,
|
||||
nvidia_base_url: Option<String>,
|
||||
openai_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvRuntimeOverrides {
|
||||
fn load() -> Self {
|
||||
Self {
|
||||
provider: std::env::var("DEEPSEEK_PROVIDER")
|
||||
.ok()
|
||||
.and_then(|v| ProviderKind::parse(&v)),
|
||||
model: std::env::var("DEEPSEEK_MODEL").ok(),
|
||||
output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
|
||||
auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
|
||||
log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
|
||||
telemetry: std::env::var("DEEPSEEK_TELEMETRY")
|
||||
.ok()
|
||||
.and_then(|v| parse_bool(&v).ok()),
|
||||
approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
|
||||
sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
|
||||
deepseek_api_key: std::env::var("DEEPSEEK_API_KEY")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_api_key: std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
nvidia_api_key: std::env::var("NVIDIA_API_KEY")
|
||||
.or_else(|_| std::env::var("NVIDIA_NIM_API_KEY"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
|
||||
.or_else(|_| std::env::var("NIM_BASE_URL"))
|
||||
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
openai_base_url: std::env::var("OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_key_for(&self, provider: ProviderKind) -> Option<String> {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => self.deepseek_api_key.clone(),
|
||||
ProviderKind::NvidiaNim => self
|
||||
.nvidia_api_key
|
||||
.clone()
|
||||
.or_else(|| self.deepseek_api_key.clone()),
|
||||
ProviderKind::Openai => self.openai_api_key.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => self.deepseek_base_url.clone(),
|
||||
ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
|
||||
ProviderKind::Openai => self.openai_base_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
deepseek_api_key: Option<OsString>,
|
||||
deepseek_base_url: Option<OsString>,
|
||||
deepseek_model: Option<OsString>,
|
||||
deepseek_provider: Option<OsString>,
|
||||
nvidia_api_key: Option<OsString>,
|
||||
nvidia_nim_api_key: Option<OsString>,
|
||||
nim_base_url: Option<OsString>,
|
||||
nvidia_base_url: Option<OsString>,
|
||||
nvidia_nim_base_url: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn without_deepseek_runtime_overrides() -> Self {
|
||||
let guard = Self {
|
||||
deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
|
||||
deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
|
||||
deepseek_model: env::var_os("DEEPSEEK_MODEL"),
|
||||
deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
|
||||
nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
|
||||
nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
|
||||
nim_base_url: env::var_os("NIM_BASE_URL"),
|
||||
nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
|
||||
nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
|
||||
};
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::remove_var("DEEPSEEK_API_KEY");
|
||||
env::remove_var("DEEPSEEK_BASE_URL");
|
||||
env::remove_var("DEEPSEEK_MODEL");
|
||||
env::remove_var("DEEPSEEK_PROVIDER");
|
||||
env::remove_var("NVIDIA_API_KEY");
|
||||
env::remove_var("NVIDIA_NIM_API_KEY");
|
||||
env::remove_var("NIM_BASE_URL");
|
||||
env::remove_var("NVIDIA_BASE_URL");
|
||||
env::remove_var("NVIDIA_NIM_BASE_URL");
|
||||
}
|
||||
guard
|
||||
}
|
||||
|
||||
unsafe fn restore_var(key: &str, value: Option<OsString>) {
|
||||
if let Some(value) = value {
|
||||
unsafe { env::set_var(key, value) };
|
||||
} else {
|
||||
unsafe { env::remove_var(key) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
|
||||
Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
|
||||
Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
|
||||
Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
|
||||
Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
|
||||
Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
|
||||
Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
|
||||
Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
|
||||
Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_deepseek_fields_are_runtime_fallbacks() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let config = ConfigToml {
|
||||
api_key: Some("root-key".to_string()),
|
||||
base_url: Some("https://api.deepseek.com".to_string()),
|
||||
default_text_model: Some("deepseek-v4-pro".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::Deepseek);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
|
||||
assert_eq!(resolved.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(resolved.model, "deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_specific_deepseek_fields_override_tui_compat_fields() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let mut config = ConfigToml {
|
||||
api_key: Some("root-key".to_string()),
|
||||
base_url: Some("https://api.deepseek.com".to_string()),
|
||||
default_text_model: Some("deepseek-v4-pro".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
config.providers.deepseek.api_key = Some("provider-key".to_string());
|
||||
config.providers.deepseek.base_url = Some("https://api.deepseeki.com".to_string());
|
||||
config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
|
||||
assert_eq!(resolved.base_url, "https://api.deepseeki.com");
|
||||
assert_eq!(resolved.model, "deepseek-v4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let config = ConfigToml {
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
..ConfigToml::default()
|
||||
};
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
|
||||
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_uses_provider_specific_credentials() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let mut config = ConfigToml {
|
||||
provider: ProviderKind::NvidiaNim,
|
||||
..ConfigToml::default()
|
||||
};
|
||||
config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
|
||||
config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
|
||||
config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
|
||||
assert_eq!(resolved.base_url, "https://nim.example/v1");
|
||||
assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_normalizes_flash_aliases() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let cli = CliRuntimeOverrides {
|
||||
provider: Some(ProviderKind::NvidiaNim),
|
||||
model: Some("deepseek-v4-flash".to_string()),
|
||||
..CliRuntimeOverrides::default()
|
||||
};
|
||||
|
||||
let resolved = ConfigToml::default().resolve_runtime_options(&cli);
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_uses_nvidia_env_credentials() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("NVIDIA_API_KEY", "nim-env-key");
|
||||
env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
|
||||
}
|
||||
|
||||
let config = ConfigToml::default();
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
|
||||
assert_eq!(resolved.base_url, "https://nim-env.example/v1");
|
||||
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("NVIDIA_API_KEY", "nim-env-key");
|
||||
env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
|
||||
}
|
||||
|
||||
let config = ConfigToml::default();
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.base_url, "https://short-nim.example/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
|
||||
}
|
||||
|
||||
let config = ConfigToml::default();
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_values_redacts_root_api_key() {
|
||||
let config = ConfigToml {
|
||||
api_key: Some("sk-deepseek-secret".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
|
||||
let values = config.list_values();
|
||||
|
||||
assert_eq!(
|
||||
values.get("api_key").map(String::as_str),
|
||||
Some("sk-d***cret")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "deepseek-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
deepseek-tools = { path = "../tools", version = "0.5.0" }
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "deepseek-execpolicy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Execution policy and approval model parity for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use deepseek_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AskForApproval {
|
||||
UnlessTrusted,
|
||||
OnFailure,
|
||||
OnRequest,
|
||||
Reject {
|
||||
sandbox_approval: bool,
|
||||
rules: bool,
|
||||
mcp_elicitations: bool,
|
||||
},
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExecPolicyAmendment {
|
||||
pub prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ExecApprovalRequirement {
|
||||
Skip {
|
||||
bypass_sandbox: bool,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
},
|
||||
NeedsApproval {
|
||||
reason: String,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
|
||||
},
|
||||
Forbidden {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExecApprovalRequirement {
|
||||
pub fn reason(&self) -> &str {
|
||||
match self {
|
||||
ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
|
||||
ExecApprovalRequirement::Forbidden { reason } => reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> &'static str {
|
||||
match self {
|
||||
ExecApprovalRequirement::Skip { .. } => "allowed",
|
||||
ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
|
||||
ExecApprovalRequirement::Forbidden { .. } => "forbidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExecPolicyDecision {
|
||||
pub allow: bool,
|
||||
pub requires_approval: bool,
|
||||
pub requirement: ExecApprovalRequirement,
|
||||
pub matched_rule: Option<String>,
|
||||
}
|
||||
|
||||
impl ExecPolicyDecision {
|
||||
pub fn reason(&self) -> &str {
|
||||
self.requirement.reason()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecPolicyContext<'a> {
|
||||
pub command: &'a str,
|
||||
pub cwd: &'a str,
|
||||
pub ask_for_approval: AskForApproval,
|
||||
pub sandbox_mode: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExecPolicyEngine {
|
||||
trusted_prefixes: Vec<String>,
|
||||
denied_prefixes: Vec<String>,
|
||||
approved_for_session: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ExecPolicyEngine {
|
||||
pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
|
||||
Self {
|
||||
trusted_prefixes,
|
||||
denied_prefixes,
|
||||
approved_for_session: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remember_session_approval(&mut self, approval_key: String) {
|
||||
self.approved_for_session.insert(approval_key);
|
||||
}
|
||||
|
||||
pub fn is_session_approved(&self, approval_key: &str) -> bool {
|
||||
self.approved_for_session.contains(approval_key)
|
||||
}
|
||||
|
||||
pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
|
||||
let normalized = normalize_command(ctx.command);
|
||||
if let Some(rule) = self
|
||||
.denied_prefixes
|
||||
.iter()
|
||||
.find(|rule| normalized.starts_with(&normalize_command(rule)))
|
||||
{
|
||||
return Ok(ExecPolicyDecision {
|
||||
allow: false,
|
||||
requires_approval: false,
|
||||
matched_rule: Some(rule.clone()),
|
||||
requirement: ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("Command blocked by denied prefix rule '{rule}'"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let trusted_rule = self
|
||||
.trusted_prefixes
|
||||
.iter()
|
||||
.find(|rule| normalized.starts_with(&normalize_command(rule)))
|
||||
.cloned();
|
||||
let is_trusted = trusted_rule.is_some();
|
||||
|
||||
let requirement = match ctx.ask_for_approval {
|
||||
AskForApproval::Never => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
AskForApproval::Reject { rules, .. } if rules => ExecApprovalRequirement::Forbidden {
|
||||
reason: "Policy is configured to reject rule-exceptions.".to_string(),
|
||||
},
|
||||
_ => ExecApprovalRequirement::NeedsApproval {
|
||||
reason: if is_trusted {
|
||||
"Approval requested by policy mode.".to_string()
|
||||
} else {
|
||||
"Unmatched command prefix requires approval.".to_string()
|
||||
},
|
||||
proposed_execpolicy_amendment: if is_trusted {
|
||||
None
|
||||
} else {
|
||||
Some(ExecPolicyAmendment {
|
||||
prefixes: vec![first_token(ctx.command)],
|
||||
})
|
||||
},
|
||||
proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
|
||||
host: ctx.cwd.to_string(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let (allow, requires_approval) = match requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => (true, false),
|
||||
ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
|
||||
ExecApprovalRequirement::Forbidden { .. } => (false, false),
|
||||
};
|
||||
|
||||
Ok(ExecPolicyDecision {
|
||||
allow,
|
||||
requires_approval,
|
||||
matched_rule: trusted_rule,
|
||||
requirement,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_command(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn first_token(command: &str) -> String {
|
||||
command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "deepseek-hooks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Hook dispatch and notifications parity for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use deepseek_protocol::EventFrame;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
ResponseStart {
|
||||
response_id: String,
|
||||
},
|
||||
ResponseDelta {
|
||||
response_id: String,
|
||||
delta: String,
|
||||
},
|
||||
ResponseEnd {
|
||||
response_id: String,
|
||||
},
|
||||
ToolLifecycle {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
phase: String,
|
||||
payload: Value,
|
||||
},
|
||||
JobLifecycle {
|
||||
job_id: String,
|
||||
phase: String,
|
||||
progress: Option<u8>,
|
||||
detail: Option<String>,
|
||||
},
|
||||
ApprovalLifecycle {
|
||||
approval_id: String,
|
||||
phase: String,
|
||||
reason: Option<String>,
|
||||
},
|
||||
GenericEventFrame {
|
||||
frame: EventFrame,
|
||||
},
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
pub fn to_json(&self) -> Value {
|
||||
serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait HookSink: Send + Sync {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StdoutHookSink;
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for StdoutHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
println!("{}", event.to_json());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JsonlHookSink {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl JsonlHookSink {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for JsonlHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!("failed to create hook log directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)
|
||||
.await
|
||||
.with_context(|| format!("failed to open hook log {}", self.path.display()))?;
|
||||
let payload = json!({
|
||||
"at": Utc::now().to_rfc3339(),
|
||||
"event": event
|
||||
});
|
||||
let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
|
||||
file.write_all(encoded.as_bytes())
|
||||
.await
|
||||
.context("failed to write hook event")?;
|
||||
file.write_all(b"\n")
|
||||
.await
|
||||
.context("failed to write hook event newline")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebhookHookSink {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl WebhookHookSink {
|
||||
pub fn new(url: String) -> Self {
|
||||
Self {
|
||||
url,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookSink for WebhookHookSink {
|
||||
async fn emit(&self, event: &HookEvent) -> Result<()> {
|
||||
let mut retries = 0usize;
|
||||
loop {
|
||||
let resp = self
|
||||
.client
|
||||
.post(&self.url)
|
||||
.json(&json!({
|
||||
"at": Utc::now().to_rfc3339(),
|
||||
"event": event,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => {
|
||||
if retries >= 2 {
|
||||
anyhow::bail!("webhook returned non-success status {}", response.status());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if retries >= 2 {
|
||||
return Err(err).context("webhook request failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
retries += 1;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct HookDispatcher {
|
||||
sinks: Vec<Arc<dyn HookSink>>,
|
||||
}
|
||||
|
||||
impl HookDispatcher {
|
||||
pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
|
||||
self.sinks.push(sink);
|
||||
}
|
||||
|
||||
pub async fn emit(&self, event: HookEvent) {
|
||||
for sink in &self.sinks {
|
||||
let _ = sink.emit(&event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "deepseek-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MCP server lifecycle and tool proxy compatibility for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,893 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ToolFilter {
|
||||
#[serde(default)]
|
||||
pub allow: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerDefinition {
|
||||
pub config: McpServerConfig,
|
||||
#[serde(default)]
|
||||
pub filter: ToolFilter,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpStartupStatus {
|
||||
Starting,
|
||||
Ready,
|
||||
Failed { error: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupUpdateEvent {
|
||||
pub server_name: String,
|
||||
pub status: McpStartupStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupFailure {
|
||||
pub server_name: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupCompleteEvent {
|
||||
pub ready: Vec<String>,
|
||||
pub failed: Vec<McpStartupFailure>,
|
||||
pub cancelled: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolDescriptor {
|
||||
pub server_name: String,
|
||||
pub tool_name: String,
|
||||
pub qualified_name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResourceDescriptor {
|
||||
pub server_name: String,
|
||||
pub uri: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub trait McpManagedClient: Send + Sync {
|
||||
fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
|
||||
fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value>;
|
||||
fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>>;
|
||||
fn read_resource(&self, uri: &str) -> Result<Value>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryMcpClient {
|
||||
tools: HashMap<String, Value>,
|
||||
resources: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl InMemoryMcpClient {
|
||||
pub fn with_tool(mut self, name: &str, sample_result: Value) -> Self {
|
||||
self.tools.insert(name.to_string(), sample_result);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_resource(mut self, uri: &str, data: Value) -> Self {
|
||||
self.resources.insert(uri.to_string(), data);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl McpManagedClient for InMemoryMcpClient {
|
||||
fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
Ok(self
|
||||
.tools
|
||||
.keys()
|
||||
.map(|name| McpToolDescriptor {
|
||||
server_name: "in-memory".to_string(),
|
||||
tool_name: name.clone(),
|
||||
qualified_name: name.clone(),
|
||||
description: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn call_tool(&self, tool_name: &str, _arguments: Value) -> Result<Value> {
|
||||
self.tools
|
||||
.get(tool_name)
|
||||
.cloned()
|
||||
.with_context(|| format!("tool '{tool_name}' not found"))
|
||||
}
|
||||
|
||||
fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
|
||||
Ok(self
|
||||
.resources
|
||||
.keys()
|
||||
.map(|uri| McpResourceDescriptor {
|
||||
server_name: "in-memory".to_string(),
|
||||
uri: uri.clone(),
|
||||
description: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn read_resource(&self, uri: &str) -> Result<Value> {
|
||||
self.resources
|
||||
.get(uri)
|
||||
.cloned()
|
||||
.with_context(|| format!("resource '{uri}' not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct McpManager {
|
||||
configs: HashMap<String, (McpServerConfig, ToolFilter)>,
|
||||
clients: HashMap<String, Box<dyn McpManagedClient>>,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn register_server(
|
||||
&mut self,
|
||||
config: McpServerConfig,
|
||||
filter: ToolFilter,
|
||||
client: Box<dyn McpManagedClient>,
|
||||
) {
|
||||
self.clients.insert(config.name.clone(), client);
|
||||
self.configs.insert(config.name.clone(), (config, filter));
|
||||
}
|
||||
|
||||
pub fn start_all<F>(&self, mut emit: F) -> McpStartupCompleteEvent
|
||||
where
|
||||
F: FnMut(McpStartupUpdateEvent),
|
||||
{
|
||||
let mut ready = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut cancelled = Vec::new();
|
||||
for (server_name, (cfg, _)) in &self.configs {
|
||||
if !cfg.enabled {
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Cancelled,
|
||||
});
|
||||
cancelled.push(server_name.clone());
|
||||
continue;
|
||||
}
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Starting,
|
||||
});
|
||||
if self.clients.contains_key(server_name) {
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Ready,
|
||||
});
|
||||
ready.push(server_name.clone());
|
||||
} else {
|
||||
let error = "client not registered".to_string();
|
||||
emit(McpStartupUpdateEvent {
|
||||
server_name: server_name.clone(),
|
||||
status: McpStartupStatus::Failed {
|
||||
error: error.clone(),
|
||||
},
|
||||
});
|
||||
failed.push(McpStartupFailure {
|
||||
server_name: server_name.clone(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
McpStartupCompleteEvent {
|
||||
ready,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_server(&mut self, server_name: &str) -> Result<()> {
|
||||
self.clients
|
||||
.remove(server_name)
|
||||
.with_context(|| format!("server '{server_name}' is not running"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unregister_server(&mut self, server_name: &str) -> Result<()> {
|
||||
let had_config = self.configs.remove(server_name).is_some();
|
||||
self.clients.remove(server_name);
|
||||
if !had_config {
|
||||
bail!("server '{server_name}' is not registered");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
let mut out = Vec::new();
|
||||
for (server_name, (_, filter)) in &self.configs {
|
||||
let Some(client) = self.clients.get(server_name) else {
|
||||
continue;
|
||||
};
|
||||
let tools = client.list_tools()?;
|
||||
for tool in tools {
|
||||
if !allowed_by_filter(&tool.tool_name, filter) {
|
||||
continue;
|
||||
}
|
||||
let qualified_name = qualify_tool_name(server_name, &tool.tool_name);
|
||||
out.push(McpToolDescriptor {
|
||||
server_name: server_name.clone(),
|
||||
tool_name: tool.tool_name,
|
||||
qualified_name,
|
||||
description: tool.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn call_tool(&self, server_name: &str, tool_name: &str, arguments: Value) -> Result<Value> {
|
||||
let client = self
|
||||
.clients
|
||||
.get(server_name)
|
||||
.with_context(|| format!("MCP server '{server_name}' not available"))?;
|
||||
client.call_tool(tool_name, arguments)
|
||||
}
|
||||
|
||||
pub fn call_qualified_tool(
|
||||
&self,
|
||||
qualified_tool_name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value> {
|
||||
let (server_name, tool_name) = parse_qualified_tool_name(qualified_tool_name)
|
||||
.with_context(|| format!("invalid qualified MCP tool name: {qualified_tool_name}"))?;
|
||||
self.call_tool(&server_name, &tool_name, arguments)
|
||||
}
|
||||
|
||||
pub fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
|
||||
let mut out = Vec::new();
|
||||
for server_name in self.configs.keys() {
|
||||
let Some(client) = self.clients.get(server_name) else {
|
||||
continue;
|
||||
};
|
||||
for mut resource in client.list_resources()? {
|
||||
resource.server_name = server_name.clone();
|
||||
out.push(resource);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<Value> {
|
||||
let client = self
|
||||
.clients
|
||||
.get(server_name)
|
||||
.with_context(|| format!("MCP server '{server_name}' not available"))?;
|
||||
client.read_resource(uri)
|
||||
}
|
||||
|
||||
pub fn update_sandbox_state(&self, sandbox_mode: &str, cwd: &str) -> Result<Vec<Value>> {
|
||||
let mut notices = Vec::new();
|
||||
for server_name in self.configs.keys() {
|
||||
notices.push(json!({
|
||||
"server_name": server_name,
|
||||
"method": "codex/sandbox-state/update",
|
||||
"params": {
|
||||
"sandbox_mode": sandbox_mode,
|
||||
"cwd": cwd
|
||||
}
|
||||
}));
|
||||
}
|
||||
Ok(notices)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn allowed_by_filter(name: &str, filter: &ToolFilter) -> bool {
|
||||
if filter.deny.iter().any(|pattern| pattern == name) {
|
||||
return false;
|
||||
}
|
||||
if filter.allow.is_empty() {
|
||||
return true;
|
||||
}
|
||||
filter.allow.iter().any(|pattern| pattern == name)
|
||||
}
|
||||
|
||||
fn sanitize_component(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn qualify_tool_name(server: &str, tool: &str) -> String {
|
||||
let mut name = format!(
|
||||
"mcp__{}__{}",
|
||||
sanitize_component(server),
|
||||
sanitize_component(tool)
|
||||
);
|
||||
if name.len() > 64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
name.hash(&mut hasher);
|
||||
let hash = format!("{:x}", hasher.finish());
|
||||
name.truncate(48);
|
||||
name.push('_');
|
||||
name.push_str(&hash[..12]);
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn parse_qualified_tool_name(value: &str) -> Result<(String, String)> {
|
||||
let Some(stripped) = value.strip_prefix("mcp__") else {
|
||||
bail!("missing mcp__ prefix");
|
||||
};
|
||||
let mut split = stripped.splitn(2, "__");
|
||||
let server = split
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("missing server segment")?;
|
||||
let tool = split
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("missing tool segment")?;
|
||||
Ok((server, tool))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[serde(default)]
|
||||
jsonrpc: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolsListParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolsCallParams {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
tool: Option<String>,
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResourcesListParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResourcesReadParams {
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerRegisterParams {
|
||||
server: McpServerConfig,
|
||||
#[serde(default)]
|
||||
filter: ToolFilter,
|
||||
#[serde(default = "default_true")]
|
||||
start: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerNameParams {
|
||||
name: String,
|
||||
}
|
||||
|
||||
struct StdioMcpState {
|
||||
manager: McpManager,
|
||||
definitions: HashMap<String, McpServerDefinition>,
|
||||
running: HashMap<String, bool>,
|
||||
lifecycle_state: String,
|
||||
}
|
||||
|
||||
pub fn run_stdio_server(
|
||||
initial_definitions: Vec<McpServerDefinition>,
|
||||
) -> Result<Vec<McpServerDefinition>> {
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
let mut stderr = io::stderr();
|
||||
let mut state = build_stdio_state(initial_definitions);
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
let line = line.context("failed to read stdio line")?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let msg = jsonrpc_error(
|
||||
None,
|
||||
JsonRpcError::parse_error(format!("invalid json: {err}")),
|
||||
);
|
||||
writeln!(stdout, "{msg}")?;
|
||||
stdout.flush()?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if request
|
||||
.jsonrpc
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != "2.0")
|
||||
{
|
||||
let response = jsonrpc_error(
|
||||
request.id,
|
||||
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
|
||||
);
|
||||
writeln!(stdout, "{response}")?;
|
||||
stdout.flush()?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match dispatch_stdio_request(&mut state, &request.method, request.params) {
|
||||
Ok((result, should_exit)) => {
|
||||
let payload = jsonrpc_result(request.id, result);
|
||||
writeln!(stdout, "{payload}")?;
|
||||
stdout.flush()?;
|
||||
if should_exit {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(err) => jsonrpc_error(request.id, err),
|
||||
};
|
||||
|
||||
writeln!(stdout, "{response}")?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
|
||||
state.lifecycle_state = "stopped".to_string();
|
||||
let _ = writeln!(stderr, "deepseek-mcp stdio server exited");
|
||||
let mut definitions: Vec<McpServerDefinition> = state.definitions.into_values().collect();
|
||||
definitions.sort_by(|a, b| a.config.name.cmp(&b.config.name));
|
||||
Ok(definitions)
|
||||
}
|
||||
|
||||
fn build_stdio_state(initial_definitions: Vec<McpServerDefinition>) -> StdioMcpState {
|
||||
let mut manager = McpManager::default();
|
||||
let mut definitions = HashMap::new();
|
||||
let mut running = HashMap::new();
|
||||
|
||||
for definition in initial_definitions {
|
||||
let name = definition.config.name.clone();
|
||||
let should_start = definition.config.enabled;
|
||||
definitions.insert(name.clone(), definition.clone());
|
||||
if should_start {
|
||||
manager.register_server(
|
||||
definition.config.clone(),
|
||||
definition.filter.clone(),
|
||||
default_stdio_client(&name),
|
||||
);
|
||||
running.insert(name, true);
|
||||
} else {
|
||||
running.insert(name, false);
|
||||
}
|
||||
}
|
||||
|
||||
StdioMcpState {
|
||||
manager,
|
||||
definitions,
|
||||
running,
|
||||
lifecycle_state: "running".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stdio_client(server_name: &str) -> Box<dyn McpManagedClient> {
|
||||
let health_uri = format!("mcp://{server_name}/health");
|
||||
let capabilities_uri = format!("mcp://{server_name}/capabilities");
|
||||
Box::new(
|
||||
InMemoryMcpClient::default()
|
||||
.with_tool(
|
||||
"health",
|
||||
json!({
|
||||
"status": "ok",
|
||||
"server_name": server_name
|
||||
}),
|
||||
)
|
||||
.with_tool(
|
||||
"capabilities",
|
||||
json!({
|
||||
"tools": ["health", "capabilities"],
|
||||
"resources": [health_uri.clone(), capabilities_uri.clone()]
|
||||
}),
|
||||
)
|
||||
.with_resource(
|
||||
&health_uri,
|
||||
json!({
|
||||
"status": "ok",
|
||||
"server_name": server_name
|
||||
}),
|
||||
)
|
||||
.with_resource(
|
||||
&capabilities_uri,
|
||||
json!({
|
||||
"server_name": server_name,
|
||||
"methods": [
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"server/list",
|
||||
"server/register",
|
||||
"server/start",
|
||||
"server/stop",
|
||||
"server/unregister"
|
||||
]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn default_rpc_methods() -> Vec<&'static str> {
|
||||
vec![
|
||||
"initialize",
|
||||
"healthz",
|
||||
"capabilities",
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"server/list",
|
||||
"server/register",
|
||||
"server/start",
|
||||
"server/stop",
|
||||
"server/unregister",
|
||||
"shutdown",
|
||||
]
|
||||
}
|
||||
|
||||
fn lifecycle_snapshot(state: &StdioMcpState) -> Value {
|
||||
let mut servers: Vec<Value> = state
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|(name, definition)| {
|
||||
let is_running = state.running.get(name).copied().unwrap_or(false);
|
||||
json!({
|
||||
"name": name,
|
||||
"enabled": definition.config.enabled,
|
||||
"running": is_running,
|
||||
"command": definition.config.command.clone(),
|
||||
"args": definition.config.args.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
servers.sort_by(|a, b| {
|
||||
let a_name = a.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let b_name = b.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
a_name.cmp(b_name)
|
||||
});
|
||||
|
||||
let running_count = state.running.values().filter(|running| **running).count();
|
||||
json!({
|
||||
"status": state.lifecycle_state,
|
||||
"servers": servers,
|
||||
"counts": {
|
||||
"defined": state.definitions.len(),
|
||||
"running": running_count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn params_or_object(params: Value) -> Value {
|
||||
if params.is_null() { json!({}) } else { params }
|
||||
}
|
||||
|
||||
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
|
||||
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
|
||||
}
|
||||
|
||||
fn parse_server_from_uri(uri: &str) -> Option<String> {
|
||||
let stripped = uri.strip_prefix("mcp://")?;
|
||||
let server = stripped.split('/').next()?;
|
||||
if server.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(server.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stdio_request(
|
||||
state: &mut StdioMcpState,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> std::result::Result<(Value, bool), JsonRpcError> {
|
||||
match method {
|
||||
"initialize" | "capabilities" => Ok((
|
||||
json!({
|
||||
"server": "deepseek-mcp",
|
||||
"transport": "stdio",
|
||||
"methods": default_rpc_methods(),
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
false,
|
||||
)),
|
||||
"healthz" => Ok((
|
||||
json!({
|
||||
"status": "ok",
|
||||
"service": "deepseek-mcp",
|
||||
"transport": "stdio",
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
false,
|
||||
)),
|
||||
"tools/list" => {
|
||||
let parsed: ToolsListParams = parse_params(params_or_object(params))?;
|
||||
let mut tools = state
|
||||
.manager
|
||||
.list_tools()
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
if let Some(server) = parsed.server {
|
||||
tools.retain(|tool| tool.server_name == server);
|
||||
}
|
||||
Ok((json!({ "tools": tools }), false))
|
||||
}
|
||||
"tools/call" => {
|
||||
let parsed: ToolsCallParams = parse_params(params_or_object(params))?;
|
||||
let ToolsCallParams {
|
||||
name,
|
||||
tool,
|
||||
server,
|
||||
arguments,
|
||||
} = parsed;
|
||||
let tool_name = name
|
||||
.or(tool)
|
||||
.context("missing tool name")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
let arguments = if arguments.is_null() {
|
||||
json!({})
|
||||
} else {
|
||||
arguments
|
||||
};
|
||||
let result = if tool_name.starts_with("mcp__") {
|
||||
state
|
||||
.manager
|
||||
.call_qualified_tool(&tool_name, arguments)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?
|
||||
} else {
|
||||
let server = server
|
||||
.context("missing server for unqualified tool")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
state
|
||||
.manager
|
||||
.call_tool(&server, &tool_name, arguments)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?
|
||||
};
|
||||
Ok((json!({ "result": result }), false))
|
||||
}
|
||||
"resources/list" => {
|
||||
let parsed: ResourcesListParams = parse_params(params_or_object(params))?;
|
||||
let mut resources = state
|
||||
.manager
|
||||
.list_resources()
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
if let Some(server) = parsed.server {
|
||||
resources.retain(|resource| resource.server_name == server);
|
||||
}
|
||||
Ok((json!({ "resources": resources }), false))
|
||||
}
|
||||
"resources/read" => {
|
||||
let parsed: ResourcesReadParams = parse_params(params_or_object(params))?;
|
||||
let ResourcesReadParams { server, uri } = parsed;
|
||||
let server_name = server
|
||||
.or_else(|| parse_server_from_uri(&uri))
|
||||
.context("missing server for resource read")
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
let value = state
|
||||
.manager
|
||||
.read_resource(&server_name, &uri)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
Ok((json!({ "resource": value }), false))
|
||||
}
|
||||
"server/list" | "servers/list" => {
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/register" | "servers/register" => {
|
||||
let parsed: ServerRegisterParams = parse_params(params_or_object(params))?;
|
||||
let name = parsed.server.name.clone();
|
||||
if name.trim().is_empty() {
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"server.name must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
if state.definitions.contains_key(&name) {
|
||||
let _ = state.manager.unregister_server(&name);
|
||||
}
|
||||
state.definitions.insert(
|
||||
name.clone(),
|
||||
McpServerDefinition {
|
||||
config: parsed.server.clone(),
|
||||
filter: parsed.filter.clone(),
|
||||
},
|
||||
);
|
||||
let should_run = parsed.start && parsed.server.enabled;
|
||||
if should_run {
|
||||
state.manager.register_server(
|
||||
parsed.server.clone(),
|
||||
parsed.filter.clone(),
|
||||
default_stdio_client(&name),
|
||||
);
|
||||
}
|
||||
state.running.insert(name, should_run);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/start" | "servers/start" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
let definition = state
|
||||
.definitions
|
||||
.get(&parsed.name)
|
||||
.cloned()
|
||||
.with_context(|| format!("server '{}' is not defined", parsed.name))
|
||||
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
|
||||
if !definition.config.enabled {
|
||||
return Err(JsonRpcError::invalid_params(format!(
|
||||
"server '{}' is disabled",
|
||||
parsed.name
|
||||
)));
|
||||
}
|
||||
if !state.running.get(&parsed.name).copied().unwrap_or(false) {
|
||||
state.manager.register_server(
|
||||
definition.config.clone(),
|
||||
definition.filter.clone(),
|
||||
default_stdio_client(&parsed.name),
|
||||
);
|
||||
state.running.insert(parsed.name, true);
|
||||
}
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/stop" | "servers/stop" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
if state.running.get(&parsed.name).copied().unwrap_or(false) {
|
||||
state
|
||||
.manager
|
||||
.stop_server(&parsed.name)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
}
|
||||
state.running.insert(parsed.name, false);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"server/unregister" | "servers/unregister" => {
|
||||
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
|
||||
if state.definitions.remove(&parsed.name).is_none() {
|
||||
return Err(JsonRpcError::invalid_params(format!(
|
||||
"server '{}' is not defined",
|
||||
parsed.name
|
||||
)));
|
||||
}
|
||||
let _ = state.manager.unregister_server(&parsed.name);
|
||||
state.running.remove(&parsed.name);
|
||||
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
|
||||
}
|
||||
"shutdown" => {
|
||||
state.lifecycle_state = "shutting_down".to_string();
|
||||
Ok((
|
||||
json!({
|
||||
"ok": true,
|
||||
"lifecycle": lifecycle_snapshot(state)
|
||||
}),
|
||||
true,
|
||||
))
|
||||
}
|
||||
_ => Err(JsonRpcError::method_not_found(method)),
|
||||
}
|
||||
}
|
||||
|
||||
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"result": result
|
||||
})
|
||||
}
|
||||
|
||||
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.unwrap_or(Value::Null),
|
||||
"error": {
|
||||
"code": err.code,
|
||||
"message": err.message,
|
||||
"data": err.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl JsonRpcError {
|
||||
fn parse_error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32700,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32600,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn method_not_found(method: &str) -> Self {
|
||||
Self {
|
||||
code: -32601,
|
||||
message: format!("unsupported method: {method}"),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_params(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32602,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: -32603,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "deepseek-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codex-style app-server protocol frames for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,451 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Envelope<T> {
|
||||
pub request_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
pub body: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadStatus {
|
||||
Running,
|
||||
Idle,
|
||||
Completed,
|
||||
Failed,
|
||||
Paused,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionSource {
|
||||
Interactive,
|
||||
Resume,
|
||||
Fork,
|
||||
Api,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: String,
|
||||
pub preview: String,
|
||||
pub ephemeral: bool,
|
||||
pub model_provider: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub status: ThreadStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
pub cwd: PathBuf,
|
||||
pub cli_version: String,
|
||||
pub source: SessionSource,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadStartParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResumeParams {
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub history: Option<Vec<Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub developer_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub personality: Option<String>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadForkParams {
|
||||
pub thread_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub developer_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub persist_extended_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadListParams {
|
||||
#[serde(default)]
|
||||
pub include_archived: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadReadParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadSetNameParams {
|
||||
pub thread_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ThreadRequest {
|
||||
Create {
|
||||
#[serde(default)]
|
||||
metadata: Value,
|
||||
},
|
||||
Start(ThreadStartParams),
|
||||
Resume(ThreadResumeParams),
|
||||
Fork(ThreadForkParams),
|
||||
List(ThreadListParams),
|
||||
Read(ThreadReadParams),
|
||||
SetName(ThreadSetNameParams),
|
||||
Archive {
|
||||
thread_id: String,
|
||||
},
|
||||
Unarchive {
|
||||
thread_id: String,
|
||||
},
|
||||
Message {
|
||||
thread_id: String,
|
||||
input: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResponse {
|
||||
pub thread_id: String,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread: Option<Thread>,
|
||||
#[serde(default)]
|
||||
pub threads: Vec<Thread>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
#[serde(default)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AppRequest {
|
||||
Capabilities,
|
||||
ConfigGet { key: String },
|
||||
ConfigSet { key: String, value: String },
|
||||
ConfigUnset { key: String },
|
||||
ConfigList,
|
||||
Models,
|
||||
ThreadLoadedList,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppResponse {
|
||||
pub ok: bool,
|
||||
pub data: Value,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
pub prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptResponse {
|
||||
pub output: String,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub events: Vec<EventFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AskForApproval {
|
||||
UnlessTrusted,
|
||||
OnFailure,
|
||||
OnRequest,
|
||||
Reject {
|
||||
sandbox_approval: bool,
|
||||
rules: bool,
|
||||
mcp_elicitations: bool,
|
||||
},
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolKind {
|
||||
Function,
|
||||
Mcp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalShellParams {
|
||||
pub command: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolPayload {
|
||||
Function {
|
||||
arguments: String,
|
||||
},
|
||||
Custom {
|
||||
input: String,
|
||||
},
|
||||
LocalShell {
|
||||
params: LocalShellParams,
|
||||
},
|
||||
Mcp {
|
||||
server: String,
|
||||
tool: String,
|
||||
raw_arguments: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
raw_tool_call_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolOutput {
|
||||
Function {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
body: Option<Value>,
|
||||
success: bool,
|
||||
},
|
||||
Mcp {
|
||||
result: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NetworkPolicyRuleAction {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NetworkPolicyAmendment {
|
||||
pub host: String,
|
||||
pub action: NetworkPolicyRuleAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReviewDecision {
|
||||
Approved,
|
||||
ApprovedExecpolicyAmendment,
|
||||
ApprovedForSession,
|
||||
NetworkPolicyAmendment {
|
||||
host: String,
|
||||
action: NetworkPolicyRuleAction,
|
||||
},
|
||||
Denied,
|
||||
Abort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpStartupStatus {
|
||||
Starting,
|
||||
Ready,
|
||||
Failed { error: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupUpdateEvent {
|
||||
pub server_name: String,
|
||||
pub status: McpStartupStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupFailure {
|
||||
pub server_name: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpStartupCompleteEvent {
|
||||
pub ready: Vec<String>,
|
||||
pub failed: Vec<McpStartupFailure>,
|
||||
pub cancelled: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkApprovalContext {
|
||||
pub host: String,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecApprovalRequestEvent {
|
||||
pub call_id: String,
|
||||
pub approval_id: String,
|
||||
pub turn_id: String,
|
||||
pub command: String,
|
||||
pub cwd: String,
|
||||
pub reason: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub network_approval_context: Option<NetworkApprovalContext>,
|
||||
#[serde(default)]
|
||||
pub proposed_execpolicy_amendment: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
|
||||
#[serde(default)]
|
||||
pub additional_permissions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub available_decisions: Vec<ReviewDecision>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum EventFrame {
|
||||
ResponseStart {
|
||||
response_id: String,
|
||||
},
|
||||
ResponseDelta {
|
||||
response_id: String,
|
||||
delta: String,
|
||||
},
|
||||
ResponseEnd {
|
||||
response_id: String,
|
||||
},
|
||||
ToolCallStart {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
arguments: Value,
|
||||
},
|
||||
ToolCallResult {
|
||||
response_id: String,
|
||||
tool_name: String,
|
||||
output: Value,
|
||||
},
|
||||
McpStartupUpdate {
|
||||
update: McpStartupUpdateEvent,
|
||||
},
|
||||
McpStartupComplete {
|
||||
summary: McpStartupCompleteEvent,
|
||||
},
|
||||
McpToolCallBegin {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
},
|
||||
McpToolCallEnd {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
ok: bool,
|
||||
},
|
||||
ExecApprovalRequest {
|
||||
request: ExecApprovalRequestEvent,
|
||||
},
|
||||
ApplyPatchApprovalRequest {
|
||||
request: ExecApprovalRequestEvent,
|
||||
},
|
||||
ElicitationRequest {
|
||||
server_name: String,
|
||||
request_id: String,
|
||||
prompt: String,
|
||||
},
|
||||
ExecCommandBegin {
|
||||
command: String,
|
||||
cwd: String,
|
||||
},
|
||||
ExecCommandOutputDelta {
|
||||
command: String,
|
||||
delta: String,
|
||||
},
|
||||
ExecCommandEnd {
|
||||
command: String,
|
||||
exit_code: i32,
|
||||
},
|
||||
PatchApplyBegin {
|
||||
path: String,
|
||||
},
|
||||
PatchApplyEnd {
|
||||
path: String,
|
||||
ok: bool,
|
||||
},
|
||||
TurnStarted {
|
||||
turn_id: String,
|
||||
},
|
||||
TurnComplete {
|
||||
turn_id: String,
|
||||
},
|
||||
TurnAborted {
|
||||
turn_id: String,
|
||||
reason: String,
|
||||
},
|
||||
Error {
|
||||
response_id: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use deepseek_protocol::{EventFrame, ThreadListParams, ThreadRequest, ThreadResumeParams};
|
||||
|
||||
#[test]
|
||||
fn thread_resume_params_round_trip() {
|
||||
let request = ThreadRequest::Resume(ThreadResumeParams {
|
||||
thread_id: "thread-123".to_string(),
|
||||
history: None,
|
||||
path: None,
|
||||
model: Some("deepseek-v4-pro".to_string()),
|
||||
model_provider: Some("deepseek".to_string()),
|
||||
cwd: None,
|
||||
approval_policy: Some("on-request".to_string()),
|
||||
sandbox: Some("workspace-write".to_string()),
|
||||
config: None,
|
||||
base_instructions: Some("base".to_string()),
|
||||
developer_instructions: Some("dev".to_string()),
|
||||
personality: Some("default".to_string()),
|
||||
persist_extended_history: true,
|
||||
});
|
||||
|
||||
let encoded = serde_json::to_string(&request).expect("serialize request");
|
||||
let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request");
|
||||
match decoded {
|
||||
ThreadRequest::Resume(params) => {
|
||||
assert_eq!(params.thread_id, "thread-123");
|
||||
assert_eq!(params.model.as_deref(), Some("deepseek-v4-pro"));
|
||||
assert!(params.persist_extended_history);
|
||||
}
|
||||
other => panic!("unexpected request: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_list_params_defaults_are_serializable() {
|
||||
let request = ThreadRequest::List(ThreadListParams {
|
||||
include_archived: false,
|
||||
limit: Some(20),
|
||||
});
|
||||
let encoded = serde_json::to_string_pretty(&request).expect("serialize list request");
|
||||
assert!(encoded.contains("include_archived"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_frame_serialization_contains_expected_tag() {
|
||||
let frame = EventFrame::TurnComplete {
|
||||
turn_id: "turn-1".to_string(),
|
||||
};
|
||||
let encoded = serde_json::to_string(&frame).expect("serialize frame");
|
||||
assert!(encoded.contains("turn_complete"));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "deepseek-state"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Session/thread persistence and recovery model for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,950 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadStatus {
|
||||
Running,
|
||||
Idle,
|
||||
Completed,
|
||||
Failed,
|
||||
Paused,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionSource {
|
||||
Interactive,
|
||||
Resume,
|
||||
Fork,
|
||||
Api,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadMetadata {
|
||||
pub id: String,
|
||||
pub rollout_path: Option<PathBuf>,
|
||||
pub preview: String,
|
||||
pub ephemeral: bool,
|
||||
pub model_provider: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub status: ThreadStatus,
|
||||
pub path: Option<PathBuf>,
|
||||
pub cwd: PathBuf,
|
||||
pub cli_version: String,
|
||||
pub source: SessionSource,
|
||||
pub name: Option<String>,
|
||||
pub sandbox_policy: Option<String>,
|
||||
pub approval_mode: Option<String>,
|
||||
pub archived: bool,
|
||||
pub archived_at: Option<i64>,
|
||||
pub git_sha: Option<String>,
|
||||
pub git_branch: Option<String>,
|
||||
pub git_origin_url: Option<String>,
|
||||
pub memory_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DynamicToolRecord {
|
||||
pub position: i64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageRecord {
|
||||
pub id: i64,
|
||||
pub thread_id: String,
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub item: Option<Value>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CheckpointRecord {
|
||||
pub thread_id: String,
|
||||
pub checkpoint_id: String,
|
||||
pub state: Value,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JobStateStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobStateRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub status: JobStateStatus,
|
||||
pub progress: Option<u8>,
|
||||
pub detail: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadListFilters {
|
||||
pub include_archived: bool,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for ThreadListFilters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_archived: false,
|
||||
limit: Some(50),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SessionIndexEntry {
|
||||
thread_id: String,
|
||||
thread_name: Option<String>,
|
||||
updated_at: i64,
|
||||
rollout_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateStore {
|
||||
db_path: PathBuf,
|
||||
session_index_path: PathBuf,
|
||||
}
|
||||
|
||||
impl StateStore {
|
||||
pub fn open(path: Option<PathBuf>) -> Result<Self> {
|
||||
let db_path = path.unwrap_or_else(default_state_db_path);
|
||||
let session_index_path = db_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join("session_index.jsonl");
|
||||
if let Some(parent) = db_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create state directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let store = Self {
|
||||
db_path,
|
||||
session_index_path,
|
||||
};
|
||||
store.init_schema()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn db_path(&self) -> &Path {
|
||||
&self.db_path
|
||||
}
|
||||
|
||||
fn conn(&self) -> Result<Connection> {
|
||||
Connection::open(&self.db_path)
|
||||
.with_context(|| format!("failed to open state db {}", self.db_path.display()))
|
||||
}
|
||||
|
||||
fn init_schema(&self) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT,
|
||||
preview TEXT NOT NULL,
|
||||
ephemeral INTEGER NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
path TEXT,
|
||||
cwd TEXT NOT NULL,
|
||||
cli_version TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
title TEXT,
|
||||
sandbox_policy TEXT,
|
||||
approval_mode TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
memory_mode TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
|
||||
thread_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
input_schema TEXT NOT NULL,
|
||||
PRIMARY KEY (thread_id, position),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
item_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(thread_id, checkpoint_id),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER,
|
||||
detail TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
|
||||
"#,
|
||||
)
|
||||
.context("failed to initialize thread schema")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
|
||||
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, memory_mode
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
|
||||
?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
rollout_path=excluded.rollout_path,
|
||||
preview=excluded.preview,
|
||||
ephemeral=excluded.ephemeral,
|
||||
model_provider=excluded.model_provider,
|
||||
created_at=excluded.created_at,
|
||||
updated_at=excluded.updated_at,
|
||||
status=excluded.status,
|
||||
path=excluded.path,
|
||||
cwd=excluded.cwd,
|
||||
cli_version=excluded.cli_version,
|
||||
source=excluded.source,
|
||||
title=excluded.title,
|
||||
sandbox_policy=excluded.sandbox_policy,
|
||||
approval_mode=excluded.approval_mode,
|
||||
archived=excluded.archived,
|
||||
archived_at=excluded.archived_at,
|
||||
git_sha=excluded.git_sha,
|
||||
git_branch=excluded.git_branch,
|
||||
git_origin_url=excluded.git_origin_url,
|
||||
memory_mode=excluded.memory_mode
|
||||
"#,
|
||||
params![
|
||||
thread.id,
|
||||
path_to_opt_string(thread.rollout_path.as_deref()),
|
||||
thread.preview,
|
||||
bool_to_i64(thread.ephemeral),
|
||||
thread.model_provider,
|
||||
thread.created_at,
|
||||
thread.updated_at,
|
||||
thread_status_to_str(&thread.status),
|
||||
path_to_opt_string(thread.path.as_deref()),
|
||||
thread.cwd.display().to_string(),
|
||||
thread.cli_version,
|
||||
session_source_to_str(&thread.source),
|
||||
thread.name,
|
||||
thread.sandbox_policy,
|
||||
thread.approval_mode,
|
||||
bool_to_i64(thread.archived),
|
||||
thread.archived_at,
|
||||
thread.git_sha,
|
||||
thread.git_branch,
|
||||
thread.git_origin_url,
|
||||
thread.memory_mode,
|
||||
],
|
||||
)
|
||||
.context("failed to upsert thread metadata")?;
|
||||
|
||||
self.append_thread_name(
|
||||
&thread.id,
|
||||
thread.name.clone(),
|
||||
thread.updated_at,
|
||||
thread.rollout_path.clone(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_thread(&self, id: &str) -> Result<Option<ThreadMetadata>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
r#"
|
||||
SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
|
||||
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, memory_mode
|
||||
FROM threads
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
params![id],
|
||||
row_to_thread,
|
||||
)
|
||||
.optional()
|
||||
.context("failed to read thread")
|
||||
}
|
||||
|
||||
pub fn list_threads(&self, filters: ThreadListFilters) -> Result<Vec<ThreadMetadata>> {
|
||||
let conn = self.conn()?;
|
||||
let sql = if filters.include_archived {
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads ORDER BY updated_at DESC LIMIT ?1"
|
||||
} else {
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql).context("failed to prepare list query")?;
|
||||
let limit = i64::try_from(filters.limit.unwrap_or(50)).unwrap_or(50);
|
||||
let mut rows = stmt
|
||||
.query(params![limit])
|
||||
.context("failed to query threads")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate thread rows")? {
|
||||
out.push(row_to_thread(row)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn mark_archived(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET archived = 1, archived_at = ?2, status = ?3 WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
Utc::now().timestamp(),
|
||||
thread_status_to_str(&ThreadStatus::Archived)
|
||||
],
|
||||
)
|
||||
.context("failed to archive thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_unarchived(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET archived = 0, archived_at = NULL WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.context("failed to unarchive thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_thread(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute("DELETE FROM threads WHERE id = ?1", params![id])
|
||||
.context("failed to delete thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_thread_memory_mode(&self, id: &str, mode: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET memory_mode = ?2 WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)
|
||||
.context("failed to update thread memory mode")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_thread_memory_mode(&self, id: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT memory_mode FROM threads WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.context("failed to read thread memory mode")
|
||||
.map(Option::flatten)
|
||||
}
|
||||
|
||||
pub fn persist_dynamic_tools(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
tools: &[DynamicToolRecord],
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn()?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.context("failed to begin dynamic tools transaction")?;
|
||||
tx.execute(
|
||||
"DELETE FROM thread_dynamic_tools WHERE thread_id = ?1",
|
||||
params![thread_id],
|
||||
)
|
||||
.context("failed to clear dynamic tools")?;
|
||||
for tool in tools {
|
||||
tx.execute(
|
||||
"INSERT INTO thread_dynamic_tools(thread_id, position, name, description, input_schema) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
thread_id,
|
||||
tool.position,
|
||||
tool.name,
|
||||
tool.description,
|
||||
tool.input_schema.to_string()
|
||||
],
|
||||
)
|
||||
.with_context(|| format!("failed to persist dynamic tool {}", tool.name))?;
|
||||
}
|
||||
tx.commit().context("failed to commit dynamic tools")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_dynamic_tools(&self, thread_id: &str) -> Result<Vec<DynamicToolRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT position, name, description, input_schema FROM thread_dynamic_tools WHERE thread_id = ?1 ORDER BY position ASC",
|
||||
)
|
||||
.context("failed to prepare get dynamic tools query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id])
|
||||
.context("failed to query dynamic tools")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate dynamic tools")? {
|
||||
let input_schema_raw: String =
|
||||
row.get(3).context("failed to read tool input schema")?;
|
||||
let input_schema: Value =
|
||||
serde_json::from_str(&input_schema_raw).with_context(|| {
|
||||
format!("failed to parse input schema for dynamic tool in thread {thread_id}")
|
||||
})?;
|
||||
out.push(DynamicToolRecord {
|
||||
position: row.get(0).context("failed to read tool position")?,
|
||||
name: row.get(1).context("failed to read tool name")?,
|
||||
description: row.get(2).context("failed to read tool description")?,
|
||||
input_schema,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn append_message(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
item: Option<Value>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn()?;
|
||||
let created_at = Utc::now().timestamp();
|
||||
let item_json = item
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.context("failed to serialize message item payload")?;
|
||||
conn.execute(
|
||||
"INSERT INTO messages(thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![thread_id, role, content, item_json, created_at],
|
||||
)
|
||||
.with_context(|| format!("failed to append message for thread {thread_id}"))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_messages(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MessageRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(500)).unwrap_or(500);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, thread_id, role, content, item_json, created_at FROM messages WHERE thread_id = ?1 ORDER BY created_at ASC LIMIT ?2",
|
||||
)
|
||||
.context("failed to prepare message listing query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id, limit])
|
||||
.with_context(|| format!("failed to list messages for thread {thread_id}"))?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate message rows")? {
|
||||
let item_json: Option<String> = row.get(4).context("failed to read item json")?;
|
||||
let item = item_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.with_context(|| {
|
||||
format!("failed to parse message item json in thread {thread_id}")
|
||||
})?;
|
||||
out.push(MessageRecord {
|
||||
id: row.get(0).context("failed to read message id")?,
|
||||
thread_id: row.get(1).context("failed to read message thread id")?,
|
||||
role: row.get(2).context("failed to read message role")?,
|
||||
content: row.get(3).context("failed to read message content")?,
|
||||
item,
|
||||
created_at: row.get(5).context("failed to read message timestamp")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn clear_messages(&self, thread_id: &str) -> Result<usize> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE thread_id = ?1",
|
||||
params![thread_id],
|
||||
)
|
||||
.with_context(|| format!("failed to clear messages for thread {thread_id}"))
|
||||
}
|
||||
|
||||
pub fn save_checkpoint(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
checkpoint_id: &str,
|
||||
state: &Value,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
let state_json =
|
||||
serde_json::to_string(state).context("failed to encode checkpoint state")?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO checkpoints(thread_id, checkpoint_id, state_json, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(thread_id, checkpoint_id) DO UPDATE SET
|
||||
state_json = excluded.state_json,
|
||||
created_at = excluded.created_at
|
||||
"#,
|
||||
params![thread_id, checkpoint_id, state_json, Utc::now().timestamp()],
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("failed to save checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_checkpoint(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
checkpoint_id: Option<&str>,
|
||||
) -> Result<Option<CheckpointRecord>> {
|
||||
let conn = self.conn()?;
|
||||
if let Some(checkpoint_id) = checkpoint_id {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
|
||||
params![thread_id, checkpoint_id],
|
||||
|row| {
|
||||
let state_json: String = row.get(2)?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
Ok(CheckpointRecord {
|
||||
thread_id: row.get(0)?,
|
||||
checkpoint_id: row.get(1)?,
|
||||
state,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| {
|
||||
format!("failed to load checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
return Ok(row);
|
||||
}
|
||||
|
||||
conn.query_row(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT 1",
|
||||
params![thread_id],
|
||||
|row| {
|
||||
let state_json: String = row.get(2)?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
Ok(CheckpointRecord {
|
||||
thread_id: row.get(0)?,
|
||||
checkpoint_id: row.get(1)?,
|
||||
state,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| format!("failed to load latest checkpoint for thread {thread_id}"))
|
||||
}
|
||||
|
||||
pub fn list_checkpoints(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<CheckpointRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT ?2",
|
||||
)
|
||||
.context("failed to prepare checkpoint list query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id, limit])
|
||||
.with_context(|| format!("failed to list checkpoints for thread {thread_id}"))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate checkpoint rows")? {
|
||||
let state_json: String = row.get(2).context("failed to read checkpoint state json")?;
|
||||
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
|
||||
out.push(CheckpointRecord {
|
||||
thread_id: row.get(0).context("failed to read checkpoint thread id")?,
|
||||
checkpoint_id: row.get(1).context("failed to read checkpoint id")?,
|
||||
state,
|
||||
created_at: row.get(3).context("failed to read checkpoint timestamp")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn delete_checkpoint(&self, thread_id: &str, checkpoint_id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"DELETE FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
|
||||
params![thread_id, checkpoint_id],
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("failed to delete checkpoint {checkpoint_id} for thread {thread_id}")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_job(&self, job: &JobStateRecord) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO jobs(id, name, status, progress, detail, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
status = excluded.status,
|
||||
progress = excluded.progress,
|
||||
detail = excluded.detail,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
job.id,
|
||||
job.name,
|
||||
job_state_status_to_str(&job.status),
|
||||
job.progress.map(i64::from),
|
||||
job.detail,
|
||||
job.created_at,
|
||||
job.updated_at
|
||||
],
|
||||
)
|
||||
.with_context(|| format!("failed to upsert job {}", job.id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_job(&self, id: &str) -> Result<Option<JobStateRecord>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
let status_raw: String = row.get(2)?;
|
||||
let progress: Option<i64> = row.get(3)?;
|
||||
Ok(JobStateRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
status: job_state_status_from_str(&status_raw),
|
||||
progress: progress.and_then(|v| u8::try_from(v).ok()),
|
||||
detail: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.with_context(|| format!("failed to read job {id}"))
|
||||
}
|
||||
|
||||
pub fn list_jobs(&self, limit: Option<usize>) -> Result<Vec<JobStateRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs ORDER BY updated_at DESC LIMIT ?1",
|
||||
)
|
||||
.context("failed to prepare job list query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![limit])
|
||||
.context("failed to query persisted jobs")?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate persisted jobs")? {
|
||||
let status_raw: String = row.get(2).context("failed to read job status")?;
|
||||
let progress: Option<i64> = row.get(3).context("failed to read job progress")?;
|
||||
out.push(JobStateRecord {
|
||||
id: row.get(0).context("failed to read job id")?,
|
||||
name: row.get(1).context("failed to read job name")?,
|
||||
status: job_state_status_from_str(&status_raw),
|
||||
progress: progress.and_then(|v| u8::try_from(v).ok()),
|
||||
detail: row.get(4).context("failed to read job detail")?,
|
||||
created_at: row.get(5).context("failed to read job created_at")?,
|
||||
updated_at: row.get(6).context("failed to read job updated_at")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn delete_job(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?1", params![id])
|
||||
.with_context(|| format!("failed to delete job {id}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_rollout_path_by_id(&self, id: &str) -> Result<Option<PathBuf>> {
|
||||
let conn = self.conn()?;
|
||||
conn.query_row(
|
||||
"SELECT rollout_path FROM threads WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.context("failed to lookup rollout path")
|
||||
.map(|opt| opt.flatten().map(PathBuf::from))
|
||||
}
|
||||
|
||||
pub fn append_thread_name(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
thread_name: Option<String>,
|
||||
updated_at: i64,
|
||||
rollout_path: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = self.session_index_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"failed to create session index directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let entry = SessionIndexEntry {
|
||||
thread_id: thread_id.to_string(),
|
||||
thread_name,
|
||||
updated_at,
|
||||
rollout_path,
|
||||
};
|
||||
let encoded =
|
||||
serde_json::to_string(&entry).context("failed to serialize session index entry")?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.session_index_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to open session index {}",
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
writeln!(file, "{encoded}").context("failed to append session index entry")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_thread_name_by_id(&self, thread_id: &str) -> Result<Option<String>> {
|
||||
let map = self.session_index_map()?;
|
||||
Ok(map
|
||||
.get(thread_id)
|
||||
.and_then(|entry| entry.thread_name.clone()))
|
||||
}
|
||||
|
||||
pub fn find_thread_names_by_ids(
|
||||
&self,
|
||||
ids: &[String],
|
||||
) -> Result<HashMap<String, Option<String>>> {
|
||||
let map = self.session_index_map()?;
|
||||
let mut out = HashMap::new();
|
||||
for id in ids {
|
||||
let name = map.get(id).and_then(|entry| entry.thread_name.clone());
|
||||
out.insert(id.clone(), name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn find_thread_path_by_name_str(&self, name: &str) -> Result<Option<PathBuf>> {
|
||||
let map = self.session_index_map()?;
|
||||
let matched = map
|
||||
.values()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.thread_name
|
||||
.as_deref()
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case(name))
|
||||
})
|
||||
.max_by_key(|entry| entry.updated_at);
|
||||
Ok(matched.and_then(|entry| entry.rollout_path.clone()))
|
||||
}
|
||||
|
||||
fn session_index_map(&self) -> Result<HashMap<String, SessionIndexEntry>> {
|
||||
if !self.session_index_path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&self.session_index_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to read session index {}",
|
||||
self.session_index_path.display()
|
||||
)
|
||||
})?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut latest = HashMap::<String, SessionIndexEntry>::new();
|
||||
for line in reader.lines() {
|
||||
let line = line.context("failed to read session index line")?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: SessionIndexEntry =
|
||||
serde_json::from_str(&line).context("failed to parse session index entry")?;
|
||||
latest.insert(parsed.thread_id.clone(), parsed);
|
||||
}
|
||||
Ok(latest)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_state_db_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".deepseek")
|
||||
.join("state.db")
|
||||
}
|
||||
|
||||
fn bool_to_i64(value: bool) -> i64 {
|
||||
if value { 1 } else { 0 }
|
||||
}
|
||||
|
||||
fn i64_to_bool(value: i64) -> bool {
|
||||
value != 0
|
||||
}
|
||||
|
||||
fn thread_status_to_str(status: &ThreadStatus) -> &'static str {
|
||||
match status {
|
||||
ThreadStatus::Running => "running",
|
||||
ThreadStatus::Idle => "idle",
|
||||
ThreadStatus::Completed => "completed",
|
||||
ThreadStatus::Failed => "failed",
|
||||
ThreadStatus::Paused => "paused",
|
||||
ThreadStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_status_from_str(value: &str) -> ThreadStatus {
|
||||
match value {
|
||||
"running" => ThreadStatus::Running,
|
||||
"idle" => ThreadStatus::Idle,
|
||||
"completed" => ThreadStatus::Completed,
|
||||
"failed" => ThreadStatus::Failed,
|
||||
"paused" => ThreadStatus::Paused,
|
||||
"archived" => ThreadStatus::Archived,
|
||||
_ => ThreadStatus::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_source_to_str(source: &SessionSource) -> &'static str {
|
||||
match source {
|
||||
SessionSource::Interactive => "interactive",
|
||||
SessionSource::Resume => "resume",
|
||||
SessionSource::Fork => "fork",
|
||||
SessionSource::Api => "api",
|
||||
SessionSource::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn session_source_from_str(value: &str) -> SessionSource {
|
||||
match value {
|
||||
"interactive" => SessionSource::Interactive,
|
||||
"resume" => SessionSource::Resume,
|
||||
"fork" => SessionSource::Fork,
|
||||
"api" => SessionSource::Api,
|
||||
_ => SessionSource::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_opt_string(path: Option<&Path>) -> Option<String> {
|
||||
path.map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
fn job_state_status_to_str(status: &JobStateStatus) -> &'static str {
|
||||
match status {
|
||||
JobStateStatus::Queued => "queued",
|
||||
JobStateStatus::Running => "running",
|
||||
JobStateStatus::Completed => "completed",
|
||||
JobStateStatus::Failed => "failed",
|
||||
JobStateStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
fn job_state_status_from_str(value: &str) -> JobStateStatus {
|
||||
match value {
|
||||
"queued" => JobStateStatus::Queued,
|
||||
"running" => JobStateStatus::Running,
|
||||
"completed" => JobStateStatus::Completed,
|
||||
"failed" => JobStateStatus::Failed,
|
||||
"cancelled" => JobStateStatus::Cancelled,
|
||||
_ => JobStateStatus::Queued,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
|
||||
let status_raw: String = row.get(7)?;
|
||||
let source_raw: String = row.get(11)?;
|
||||
let rollout_path: Option<String> = row.get(1)?;
|
||||
let path: Option<String> = row.get(8)?;
|
||||
Ok(ThreadMetadata {
|
||||
id: row.get(0)?,
|
||||
rollout_path: rollout_path.map(PathBuf::from),
|
||||
preview: row.get(2)?,
|
||||
ephemeral: i64_to_bool(row.get(3)?),
|
||||
model_provider: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
status: thread_status_from_str(&status_raw),
|
||||
path: path.map(PathBuf::from),
|
||||
cwd: PathBuf::from(row.get::<_, String>(9)?),
|
||||
cli_version: row.get(10)?,
|
||||
source: session_source_from_str(&source_raw),
|
||||
name: row.get(12)?,
|
||||
sandbox_policy: row.get(13)?,
|
||||
approval_mode: row.get(14)?,
|
||||
archived: i64_to_bool(row.get(15)?),
|
||||
archived_at: row.get(16)?,
|
||||
git_sha: row.get(17)?,
|
||||
git_branch: row.get(18)?,
|
||||
git_origin_url: row.get(19)?,
|
||||
memory_mode: row.get(20)?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use deepseek_state::{SessionSource, StateStore, ThreadListFilters, ThreadMetadata, ThreadStatus};
|
||||
|
||||
fn temp_state_path(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"deepseek_state_test_{}_{}_{}.db",
|
||||
label,
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_resume_thread_metadata() {
|
||||
let path = temp_state_path("upsert_resume");
|
||||
let store = StateStore::open(Some(path.clone())).expect("open state store");
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let thread = ThreadMetadata {
|
||||
id: "thread-test-1".to_string(),
|
||||
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
|
||||
preview: "hello".to_string(),
|
||||
ephemeral: false,
|
||||
model_provider: "deepseek".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: ThreadStatus::Running,
|
||||
path: Some(PathBuf::from("/tmp/project")),
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
cli_version: "0.0.0-test".to_string(),
|
||||
source: SessionSource::Interactive,
|
||||
name: Some("Test Thread".to_string()),
|
||||
sandbox_policy: Some("workspace-write".to_string()),
|
||||
approval_mode: Some("on-request".to_string()),
|
||||
archived: false,
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
memory_mode: Some("extended".to_string()),
|
||||
};
|
||||
store.upsert_thread(&thread).expect("upsert thread");
|
||||
|
||||
let loaded = store
|
||||
.get_thread("thread-test-1")
|
||||
.expect("read thread")
|
||||
.expect("thread must exist");
|
||||
assert_eq!(loaded.id, "thread-test-1");
|
||||
assert_eq!(loaded.name.as_deref(), Some("Test Thread"));
|
||||
assert_eq!(loaded.memory_mode.as_deref(), Some("extended"));
|
||||
assert_eq!(
|
||||
loaded.rollout_path,
|
||||
Some(PathBuf::from("/tmp/rollout.jsonl"))
|
||||
);
|
||||
|
||||
store
|
||||
.mark_archived("thread-test-1")
|
||||
.expect("archive thread");
|
||||
let archived = store
|
||||
.get_thread("thread-test-1")
|
||||
.expect("read archived thread")
|
||||
.expect("thread exists after archive");
|
||||
assert!(archived.archived);
|
||||
|
||||
let listed = store
|
||||
.list_threads(ThreadListFilters {
|
||||
include_archived: true,
|
||||
limit: Some(10),
|
||||
})
|
||||
.expect("list threads");
|
||||
assert!(!listed.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "deepseek-tools"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Tool invocation lifecycle, schema validation, and scheduler parallelism for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -0,0 +1,202 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use deepseek_protocol::{ToolKind, ToolOutput, ToolPayload};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolSpec {
|
||||
pub name: String,
|
||||
pub input_schema: Value,
|
||||
pub output_schema: Value,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfiguredToolSpec {
|
||||
pub spec: ToolSpec,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallSource {
|
||||
Direct,
|
||||
JsRepl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub name: String,
|
||||
pub payload: ToolPayload,
|
||||
pub source: ToolCallSource,
|
||||
pub raw_tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn execution_subject(&self, fallback_cwd: &str) -> (String, String, &'static str) {
|
||||
match &self.payload {
|
||||
ToolPayload::LocalShell { params } => (
|
||||
params.command.clone(),
|
||||
params
|
||||
.cwd
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_cwd.to_string()),
|
||||
"shell",
|
||||
),
|
||||
_ => (self.name.clone(), fallback_cwd.to_string(), "tool"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolInvocation {
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
pub payload: ToolPayload,
|
||||
pub source: ToolCallSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FunctionCallError {
|
||||
ToolNotFound { name: String },
|
||||
KindMismatch { expected: ToolKind, got: ToolKind },
|
||||
MutatingToolRejected { name: String },
|
||||
TimedOut { name: String, timeout_ms: u64 },
|
||||
Cancelled { name: String },
|
||||
ExecutionFailed { name: String, error: String },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ToolHandler: Send + Sync {
|
||||
fn kind(&self) -> ToolKind;
|
||||
fn matches_kind(&self, kind: ToolKind) -> bool {
|
||||
self.kind() == kind
|
||||
}
|
||||
fn is_mutating(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ToolCallRuntime {
|
||||
pub parallel_execution: Arc<RwLock<()>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ToolRegistry {
|
||||
handlers: HashMap<String, Arc<dyn ToolHandler>>,
|
||||
specs: HashMap<String, ConfiguredToolSpec>,
|
||||
runtime: ToolCallRuntime,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn register(&mut self, spec: ToolSpec, handler: Arc<dyn ToolHandler>) -> Result<()> {
|
||||
let name = spec.name.clone();
|
||||
self.specs.insert(
|
||||
name.clone(),
|
||||
ConfiguredToolSpec {
|
||||
supports_parallel_tool_calls: spec.supports_parallel_tool_calls,
|
||||
spec,
|
||||
},
|
||||
);
|
||||
self.handlers.insert(name, handler);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_specs(&self) -> Vec<ConfiguredToolSpec> {
|
||||
self.specs.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
call: ToolCall,
|
||||
allow_mutating: bool,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError> {
|
||||
let handler = self.handlers.get(&call.name).cloned().ok_or_else(|| {
|
||||
FunctionCallError::ToolNotFound {
|
||||
name: call.name.clone(),
|
||||
}
|
||||
})?;
|
||||
let configured =
|
||||
self.specs
|
||||
.get(&call.name)
|
||||
.cloned()
|
||||
.ok_or_else(|| FunctionCallError::ToolNotFound {
|
||||
name: call.name.clone(),
|
||||
})?;
|
||||
|
||||
let payload_kind = tool_payload_kind(&call.payload);
|
||||
let expected = handler.kind();
|
||||
if !handler.matches_kind(payload_kind) {
|
||||
return Err(FunctionCallError::KindMismatch {
|
||||
expected,
|
||||
got: payload_kind,
|
||||
});
|
||||
}
|
||||
if handler.is_mutating() && !allow_mutating {
|
||||
return Err(FunctionCallError::MutatingToolRejected { name: call.name });
|
||||
}
|
||||
|
||||
let invocation = ToolInvocation {
|
||||
call_id: call
|
||||
.raw_tool_call_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("tool-call-{}", uuid::Uuid::new_v4())),
|
||||
tool_name: call.name.clone(),
|
||||
payload: call.payload,
|
||||
source: call.source,
|
||||
};
|
||||
|
||||
if configured.supports_parallel_tool_calls {
|
||||
let _guard = self.runtime.parallel_execution.read().await;
|
||||
self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation)
|
||||
.await
|
||||
} else {
|
||||
let _guard = self.runtime.parallel_execution.write().await;
|
||||
self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_with_timeout(
|
||||
&self,
|
||||
handler: Arc<dyn ToolHandler>,
|
||||
timeout_ms: Option<u64>,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, FunctionCallError> {
|
||||
if let Some(timeout_ms) = timeout_ms {
|
||||
let name = invocation.tool_name.clone();
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(timeout_ms),
|
||||
handler.handle(invocation),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(FunctionCallError::TimedOut { name, timeout_ms }),
|
||||
}
|
||||
} else {
|
||||
handler.handle(invocation).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_payload_kind(payload: &ToolPayload) -> ToolKind {
|
||||
match payload {
|
||||
ToolPayload::Mcp { .. } => ToolKind::Mcp,
|
||||
ToolPayload::Function { .. }
|
||||
| ToolPayload::Custom { .. }
|
||||
| ToolPayload::LocalShell { .. } => ToolKind::Function,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use deepseek_protocol::{ToolKind, ToolOutput, ToolPayload};
|
||||
use deepseek_tools::{
|
||||
ToolCall, ToolCallSource, ToolHandler, ToolInvocation, ToolRegistry, ToolSpec,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
struct EchoHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for EchoHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn is_mutating(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> std::result::Result<ToolOutput, deepseek_tools::FunctionCallError> {
|
||||
Ok(ToolOutput::Function {
|
||||
body: Some(json!({
|
||||
"tool": invocation.tool_name,
|
||||
"call_id": invocation.call_id
|
||||
})),
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatches_function_tool_with_parallel_flag() {
|
||||
let mut registry = ToolRegistry::default();
|
||||
registry
|
||||
.register(
|
||||
ToolSpec {
|
||||
name: "echo".to_string(),
|
||||
input_schema: json!({"type":"object"}),
|
||||
output_schema: json!({"type":"object"}),
|
||||
supports_parallel_tool_calls: true,
|
||||
timeout_ms: Some(1000),
|
||||
},
|
||||
Arc::new(EchoHandler),
|
||||
)
|
||||
.expect("register tool");
|
||||
|
||||
let output = registry
|
||||
.dispatch(
|
||||
ToolCall {
|
||||
name: "echo".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{\"message\":\"hi\"}".to_string(),
|
||||
},
|
||||
source: ToolCallSource::Direct,
|
||||
raw_tool_call_id: Some("call-1".to_string()),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("dispatch tool");
|
||||
match output {
|
||||
ToolOutput::Function { success, .. } => assert!(success),
|
||||
other => panic!("unexpected output: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "deepseek-tui-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Event-driven TUI state machine scaffold for DeepSeek workspace architecture"
|
||||
@@ -0,0 +1,192 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Pane {
|
||||
Chat,
|
||||
Diff,
|
||||
Tasks,
|
||||
Agents,
|
||||
Status,
|
||||
Jobs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UiEvent {
|
||||
KeyPressed(char),
|
||||
PromptSubmitted(String),
|
||||
ResponseDelta(String),
|
||||
ToolStarted(String),
|
||||
ToolFinished(String),
|
||||
JobQueued(String),
|
||||
JobProgress { job_id: String, progress: u8 },
|
||||
JobCompleted(String),
|
||||
ApprovalRequested(String),
|
||||
ApprovalResolved(String),
|
||||
PauseRequested,
|
||||
ResumeRequested,
|
||||
Tick,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UiEffect {
|
||||
Render,
|
||||
PersistCheckpoint,
|
||||
ScheduleBackgroundRefresh,
|
||||
EmitStatusLine(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UiState {
|
||||
pub active_pane: Pane,
|
||||
pub paused: bool,
|
||||
pub last_response_delta: Option<String>,
|
||||
pub active_tool: Option<String>,
|
||||
pub pending_tasks: usize,
|
||||
pub active_jobs: usize,
|
||||
pub pending_approvals: usize,
|
||||
pub status_line: String,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active_pane: Pane::Chat,
|
||||
paused: false,
|
||||
last_response_delta: None,
|
||||
active_tool: None,
|
||||
pending_tasks: 0,
|
||||
active_jobs: 0,
|
||||
pending_approvals: 0,
|
||||
status_line: "ready".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn reduce(&mut self, event: UiEvent) -> Vec<UiEffect> {
|
||||
match event {
|
||||
UiEvent::KeyPressed('1') => {
|
||||
self.active_pane = Pane::Chat;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('2') => {
|
||||
self.active_pane = Pane::Diff;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('3') => {
|
||||
self.active_pane = Pane::Tasks;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('4') => {
|
||||
self.active_pane = Pane::Agents;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::KeyPressed('5') => {
|
||||
self.active_pane = Pane::Jobs;
|
||||
vec![UiEffect::Render]
|
||||
}
|
||||
UiEvent::PromptSubmitted(_) => {
|
||||
self.pending_tasks = self.pending_tasks.saturating_add(1);
|
||||
self.status_line = "prompt submitted".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ResponseDelta(delta) => {
|
||||
self.last_response_delta = Some(delta);
|
||||
self.status_line = "streaming response".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ToolStarted(name) => {
|
||||
self.active_tool = Some(name.clone());
|
||||
self.status_line = format!("tool running: {name}");
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ToolFinished(name) => {
|
||||
self.active_tool = None;
|
||||
self.pending_tasks = self.pending_tasks.saturating_sub(1);
|
||||
self.status_line = format!("tool finished: {name}");
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::JobQueued(_) => {
|
||||
self.active_jobs = self.active_jobs.saturating_add(1);
|
||||
self.status_line = "job queued".to_string();
|
||||
vec![UiEffect::Render, UiEffect::PersistCheckpoint]
|
||||
}
|
||||
UiEvent::JobProgress { progress, .. } => {
|
||||
self.status_line = format!("job progress: {}%", progress.min(100));
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::JobCompleted(_) => {
|
||||
self.active_jobs = self.active_jobs.saturating_sub(1);
|
||||
self.status_line = "job completed".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ApprovalRequested(_) => {
|
||||
self.pending_approvals = self.pending_approvals.saturating_add(1);
|
||||
self.status_line = "approval requested".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ApprovalResolved(_) => {
|
||||
self.pending_approvals = self.pending_approvals.saturating_sub(1);
|
||||
self.status_line = "approval resolved".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::PersistCheckpoint,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::PauseRequested => {
|
||||
self.paused = true;
|
||||
self.status_line = "paused".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::ResumeRequested => {
|
||||
self.paused = false;
|
||||
self.status_line = "resumed".to_string();
|
||||
vec![
|
||||
UiEffect::Render,
|
||||
UiEffect::EmitStatusLine(self.status_line.clone()),
|
||||
]
|
||||
}
|
||||
UiEvent::Tick => vec![UiEffect::ScheduleBackgroundRefresh],
|
||||
UiEvent::KeyPressed(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> String {
|
||||
format!(
|
||||
"pane={:?};paused={};pending_tasks={};active_jobs={};pending_approvals={};active_tool={};status={}",
|
||||
self.active_pane,
|
||||
self.paused,
|
||||
self.pending_tasks,
|
||||
self.active_jobs,
|
||||
self.pending_approvals,
|
||||
self.active_tool.clone().unwrap_or_default(),
|
||||
self.status_line
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use deepseek_tui_core::{Pane, UiEvent, UiState};
|
||||
|
||||
#[test]
|
||||
fn reducer_produces_stable_snapshot_for_core_workflow() {
|
||||
let mut state = UiState::default();
|
||||
state.reduce(UiEvent::PromptSubmitted("hello".to_string()));
|
||||
state.reduce(UiEvent::ToolStarted("web.search".to_string()));
|
||||
state.reduce(UiEvent::ResponseDelta("partial".to_string()));
|
||||
state.reduce(UiEvent::ToolFinished("web.search".to_string()));
|
||||
state.reduce(UiEvent::ApprovalRequested("approval-1".to_string()));
|
||||
state.reduce(UiEvent::ApprovalResolved("approval-1".to_string()));
|
||||
state.reduce(UiEvent::JobQueued("job-1".to_string()));
|
||||
state.reduce(UiEvent::JobProgress {
|
||||
job_id: "job-1".to_string(),
|
||||
progress: 60,
|
||||
});
|
||||
state.reduce(UiEvent::JobCompleted("job-1".to_string()));
|
||||
state.reduce(UiEvent::KeyPressed('5'));
|
||||
|
||||
assert_eq!(state.active_pane, Pane::Jobs);
|
||||
assert_eq!(
|
||||
state.snapshot(),
|
||||
"pane=Jobs;paused=false;pending_tasks=0;active_jobs=0;pending_approvals=0;active_tool=;status=job completed"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Terminal UI for DeepSeek"
|
||||
|
||||
[[bin]]
|
||||
name = "deepseek-tui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
base64 = "0.22.1"
|
||||
axum = { version = "0.8.4", features = ["json"] }
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
colored = "3.0.0"
|
||||
crossterm = "0.28"
|
||||
csv = "1.4"
|
||||
dotenvy = "0.15.7"
|
||||
dirs = "6.0.0"
|
||||
futures-util = "0.3.31"
|
||||
indicatif = "0.18.0"
|
||||
ratatui = "0.29"
|
||||
regex = "1.11"
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
|
||||
rustyline = "15.0.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
shellexpand = "3"
|
||||
toml = "0.9.7"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
unicode-width = "0.2"
|
||||
unicode-segmentation = "1.12"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
tokio-stream = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
starlark = "0.13.0"
|
||||
tiny_http = "0.12"
|
||||
portable-pty = "0.8"
|
||||
zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
pdf-extract = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.60", features = ["Win32_Foundation"] }
|
||||
File renamed without changes.
@@ -403,7 +403,7 @@ impl AutomationManager {
|
||||
}
|
||||
out.push(record);
|
||||
}
|
||||
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
out.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -522,7 +522,7 @@ impl AutomationManager {
|
||||
out.push(run);
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
out.sort_by_key(|r| std::cmp::Reverse(r.created_at));
|
||||
if let Some(limit) = limit {
|
||||
out.truncate(limit);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File renamed without changes.
@@ -0,0 +1,124 @@
|
||||
//! Local media attachment commands.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::tui::app::App;
|
||||
|
||||
pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return CommandResult::error("Usage: /attach <image-or-video-path>");
|
||||
};
|
||||
|
||||
let path = resolve_attachment_path(raw_path, &app.workspace);
|
||||
let Ok(path) = path.canonicalize() else {
|
||||
return CommandResult::error(format!("Attachment not found: {}", path.display()));
|
||||
};
|
||||
if !path.is_file() {
|
||||
return CommandResult::error(format!("Attachment is not a file: {}", path.display()));
|
||||
}
|
||||
|
||||
let Some(kind) = media_kind(&path) else {
|
||||
return CommandResult::error(
|
||||
"Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.",
|
||||
);
|
||||
};
|
||||
|
||||
app.insert_media_attachment(kind, &path, None);
|
||||
CommandResult::message(format!("Attached {kind}: {}", path.display()))
|
||||
}
|
||||
|
||||
fn resolve_attachment_path(raw_path: &str, workspace: &Path) -> PathBuf {
|
||||
let unquoted = raw_path.trim().trim_matches('"').trim_matches('\'');
|
||||
let path = expand_home(unquoted);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
workspace.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/")
|
||||
&& let Some(home) = std::env::var_os("HOME")
|
||||
{
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn media_kind(path: &Path) -> Option<&'static str> {
|
||||
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ppm" => Some("image"),
|
||||
"mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv" => Some("video"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn app_with_workspace(tmpdir: &TempDir) -> App {
|
||||
App::new(
|
||||
TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: false,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
},
|
||||
&Config::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_inserts_image_reference() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
let image_path = tmpdir.path().join("photo.png");
|
||||
std::fs::write(&image_path, b"not actually decoded").expect("write image fixture");
|
||||
let mut app = app_with_workspace(&tmpdir);
|
||||
|
||||
let result = attach(&mut app, Some("photo.png"));
|
||||
|
||||
assert!(result.message.expect("message").contains("Attached image"));
|
||||
assert!(app.input.contains("[Attached image:"));
|
||||
let canonical_path = image_path.canonicalize().expect("canonical image path");
|
||||
assert!(app.input.contains(&canonical_path.display().to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_rejects_unsupported_extension() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
std::fs::write(tmpdir.path().join("notes.txt"), b"text").expect("write fixture");
|
||||
let mut app = app_with_workspace(&tmpdir);
|
||||
|
||||
let result = attach(&mut app, Some("notes.txt"));
|
||||
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.expect("message")
|
||||
.contains("Unsupported attachment type")
|
||||
);
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Config commands: config, settings, mode switches, trust, logout
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name};
|
||||
use crate::palette;
|
||||
@@ -82,6 +84,14 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.auto_compact = settings.auto_compact;
|
||||
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
|
||||
}
|
||||
"calm_mode" | "calm" => {
|
||||
app.calm_mode = settings.calm_mode;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"low_motion" | "motion" => {
|
||||
app.low_motion = settings.low_motion;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"show_thinking" | "thinking" => {
|
||||
app.show_thinking = settings.show_thinking;
|
||||
app.mark_history_updated();
|
||||
@@ -90,13 +100,22 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.show_tool_details = settings.show_tool_details;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"composer_density" | "composer" => {
|
||||
app.composer_density =
|
||||
crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"composer_border" | "border" => {
|
||||
app.composer_border = settings.composer_border;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"transcript_spacing" | "spacing" => {
|
||||
app.transcript_spacing =
|
||||
crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let mode = match settings.default_mode.as_str() {
|
||||
"agent" | "normal" => AppMode::Agent,
|
||||
"plan" => AppMode::Plan,
|
||||
"yolo" => AppMode::Yolo,
|
||||
_ => AppMode::Agent,
|
||||
};
|
||||
let mode = AppMode::from_setting(&settings.default_mode);
|
||||
app.set_mode(mode);
|
||||
}
|
||||
"max_history" | "history" => {
|
||||
@@ -125,13 +144,18 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let display_value = match key.as_str() {
|
||||
"default_mode" | "mode" => settings.default_mode.clone(),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
|
||||
let message = if persist {
|
||||
if let Err(e) = settings.save() {
|
||||
return CommandResult::error(format!("Failed to save: {e}"));
|
||||
}
|
||||
format!("{key} = {value} (saved)")
|
||||
format!("{key} = {display_value} (saved)")
|
||||
} else {
|
||||
format!("{key} = {value} (session only, add --save to persist)")
|
||||
format!("{key} = {display_value} (session only, add --save to persist)")
|
||||
};
|
||||
|
||||
CommandResult {
|
||||
@@ -180,10 +204,10 @@ pub fn yolo(app: &mut App) -> CommandResult {
|
||||
CommandResult::message("YOLO mode enabled - shell + trust + auto-approve!")
|
||||
}
|
||||
|
||||
/// Enable normal mode (read-only chat, suggestions before approvals)
|
||||
/// Legacy alias for the removed normal mode.
|
||||
pub fn normal_mode(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Normal);
|
||||
CommandResult::message("Normal mode enabled.")
|
||||
app.set_mode(AppMode::Agent);
|
||||
CommandResult::message("Normal mode was removed. Switched to Agent mode.")
|
||||
}
|
||||
|
||||
/// Enable agent mode (autonomous tool use with approvals)
|
||||
@@ -200,10 +224,117 @@ pub fn plan_mode(app: &mut App) -> CommandResult {
|
||||
)
|
||||
}
|
||||
|
||||
/// Enable trust mode (file access outside workspace)
|
||||
pub fn trust(app: &mut App) -> CommandResult {
|
||||
app.trust_mode = true;
|
||||
CommandResult::message("Trust mode enabled - can access files outside workspace")
|
||||
/// Manage workspace-level trust and the per-path allowlist.
|
||||
///
|
||||
/// Subcommands:
|
||||
/// - `/trust` – show current state and trusted external paths
|
||||
/// - `/trust on` – legacy: trust the entire workspace (turn off all path checks)
|
||||
/// - `/trust off` – disable workspace-level trust mode
|
||||
/// - `/trust add <path>` – add a directory to the allowlist (#29)
|
||||
/// - `/trust remove <path>` (alias `rm`) – remove a path from the allowlist
|
||||
/// - `/trust list` – list trusted external paths for this workspace
|
||||
pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let raw = arg.map(str::trim).unwrap_or("");
|
||||
let mut parts = raw.splitn(2, char::is_whitespace);
|
||||
let sub = parts.next().unwrap_or("").to_lowercase();
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
let workspace = app.workspace.clone();
|
||||
|
||||
match sub.as_str() {
|
||||
"" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
|
||||
"on" | "enable" | "yes" | "y" => {
|
||||
app.trust_mode = true;
|
||||
CommandResult::message(
|
||||
"Workspace trust mode enabled — agent file tools can now read/write any path. \
|
||||
Use `/trust off` to revert; prefer `/trust add <path>` for a narrower opt-in.",
|
||||
)
|
||||
}
|
||||
"off" | "disable" | "no" | "n" => {
|
||||
app.trust_mode = false;
|
||||
CommandResult::message("Workspace trust mode disabled.")
|
||||
}
|
||||
"add" => trust_add(&workspace, rest),
|
||||
"remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
|
||||
other => CommandResult::error(format!(
|
||||
"Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add <path>`, or `/trust remove <path>`."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
|
||||
let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!(
|
||||
"Workspace trust mode: {}",
|
||||
if app.trust_mode {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
));
|
||||
if trust.paths().is_empty() {
|
||||
if force_paths {
|
||||
lines.push("No external paths trusted from this workspace.".to_string());
|
||||
} else {
|
||||
lines.push(
|
||||
"No external paths trusted yet. Use `/trust add <path>` to allow a directory."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
|
||||
for path in trust.paths() {
|
||||
lines.push(format!(" • {}", path.display()));
|
||||
}
|
||||
}
|
||||
CommandResult::message(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error(
|
||||
"Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
|
||||
);
|
||||
}
|
||||
let path = PathBuf::from(expand_tilde(raw));
|
||||
if !path.exists() {
|
||||
return CommandResult::error(format!(
|
||||
"Path not found: {} — supply an existing directory or file.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
match crate::workspace_trust::add(workspace, &path) {
|
||||
Ok(stored) => CommandResult::message(format!(
|
||||
"Added to trust list for this workspace: {}",
|
||||
stored.display()
|
||||
)),
|
||||
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error("Usage: /trust remove <path>");
|
||||
}
|
||||
let path = PathBuf::from(expand_tilde(raw));
|
||||
match crate::workspace_trust::remove(workspace, &path) {
|
||||
Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
|
||||
Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
|
||||
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_tilde(raw: &str) -> String {
|
||||
if let Some(rest) = raw.strip_prefix("~/")
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.join(rest).to_string_lossy().into_owned();
|
||||
} else if raw == "~"
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.to_string_lossy().into_owned();
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Logout - clear API key and return to onboarding
|
||||
@@ -310,6 +441,7 @@ mod tests {
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
@@ -339,7 +471,7 @@ mod tests {
|
||||
fn test_mode_switch_commands() {
|
||||
let mut app = create_test_app();
|
||||
let _ = normal_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Normal);
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let _ = agent_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let _ = plan_mode(&mut app);
|
||||
@@ -378,11 +510,11 @@ mod tests {
|
||||
fn test_set_model_updates_app_state() {
|
||||
let mut app = create_test_app();
|
||||
let _old_model = app.model.clone();
|
||||
let result = set_config(&mut app, Some("model deepseek-reasoner"));
|
||||
let result = set_config(&mut app, Some("model deepseek-v4-flash"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("model = deepseek-reasoner"));
|
||||
assert_eq!(app.model, "deepseek-reasoner");
|
||||
assert!(msg.contains("model = deepseek-v4-flash"));
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
@@ -402,10 +534,36 @@ mod tests {
|
||||
#[test]
|
||||
fn test_set_model_with_save_flag() {
|
||||
let mut app = create_test_app();
|
||||
let _result = set_config(&mut app, Some("model deepseek-reasoner --save"));
|
||||
let _result = set_config(&mut app, Some("model deepseek-v4-flash --save"));
|
||||
// Note: This test may fail in environments where settings can't be saved
|
||||
// The important thing is that the model is updated
|
||||
assert_eq!(app.model, "deepseek-reasoner");
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_mode_normal_save_reports_normalized_value() {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-default-mode-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("default_mode normal --save"));
|
||||
let msg = result.message.unwrap();
|
||||
assert_eq!(msg, "default_mode = agent (saved)");
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
|
||||
let settings_path = Settings::path().unwrap();
|
||||
let saved = fs::read_to_string(settings_path).unwrap();
|
||||
assert!(saved.contains("default_mode = \"agent\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -447,16 +605,44 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_enables_flag() {
|
||||
fn test_set_composer_border_updates_live_app() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
app.composer_border = true;
|
||||
|
||||
let result = set_config(&mut app, Some("composer_border false"));
|
||||
|
||||
assert!(result.message.is_some());
|
||||
assert!(!app.composer_border);
|
||||
assert!(app.needs_redraw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_on_enables_flag() {
|
||||
let mut app = create_test_app();
|
||||
assert!(!app.trust_mode);
|
||||
let result = trust(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Trust mode enabled"));
|
||||
let result = trust(&mut app, Some("on"));
|
||||
let msg = result.message.expect("message");
|
||||
assert!(msg.contains("Workspace trust mode enabled"));
|
||||
assert!(app.trust_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_status_default_lists_state() {
|
||||
let mut app = create_test_app();
|
||||
let result = trust(&mut app, None);
|
||||
let msg = result.message.expect("status message");
|
||||
assert!(msg.contains("Workspace trust mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_add_requires_path() {
|
||||
let mut app = create_test_app();
|
||||
let result = trust(&mut app, Some("add"));
|
||||
let msg = result.message.expect("error message");
|
||||
assert!(msg.starts_with("Error:"), "got {msg:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logout_clears_api_key_state() {
|
||||
let _lock = lock_test_env();
|
||||
@@ -465,7 +651,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-logout-test-{}-{}",
|
||||
"deepseek-tui-logout-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -74,7 +74,9 @@ pub fn exit() -> CommandResult {
|
||||
CommandResult::action(AppAction::Quit)
|
||||
}
|
||||
|
||||
/// Switch or view current model
|
||||
/// Switch or view current model. With no argument, open the two-pane
|
||||
/// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable
|
||||
/// way to flip both knobs without memorising the docs.
|
||||
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
if let Some(name) = model_name {
|
||||
let Some(model_id) = normalize_model_name(name) else {
|
||||
@@ -93,11 +95,7 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
AppAction::UpdateCompaction(app.compaction_config()),
|
||||
)
|
||||
} else {
|
||||
let common = COMMON_DEEPSEEK_MODELS.join(", ");
|
||||
CommandResult::message(format!(
|
||||
"Current model: {}\nCommon models: {}\nTip: any valid DeepSeek model ID is accepted. Run /models to fetch live IDs from your API endpoint.",
|
||||
app.model, common
|
||||
))
|
||||
CommandResult::action(AppAction::OpenModelPicker)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +130,7 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
|
||||
let mut stats = String::new();
|
||||
|
||||
// Basic info
|
||||
let _ = writeln!(stats, "DeepSeek CLI Home Dashboard");
|
||||
let _ = writeln!(stats, "DeepSeek TUI Home Dashboard");
|
||||
let _ = writeln!(stats, "============================================");
|
||||
|
||||
// Model & mode
|
||||
@@ -180,11 +178,12 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
|
||||
let _ = writeln!(stats, "\nMode Tips");
|
||||
let _ = writeln!(stats, "--------------------------------------------");
|
||||
match app.mode {
|
||||
AppMode::Normal => {
|
||||
let _ = writeln!(stats, "Normal mode - Chat with the assistant");
|
||||
}
|
||||
AppMode::Agent => {
|
||||
let _ = writeln!(stats, "Agent mode - Use tools for autonomous tasks");
|
||||
let _ = writeln!(
|
||||
stats,
|
||||
" Use Ctrl+X to review in Plan mode before executing"
|
||||
);
|
||||
let _ = writeln!(stats, " Type /yolo to enable full tool access");
|
||||
}
|
||||
AppMode::Yolo => {
|
||||
@@ -211,10 +210,11 @@ mod tests {
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("/tmp/test-workspace"),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("/tmp/test-skills"),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
@@ -327,16 +327,16 @@ mod tests {
|
||||
fn test_model_change_updates_state() {
|
||||
let mut app = create_test_app();
|
||||
let old_model = app.model.clone();
|
||||
let result = model(&mut app, Some("deepseek-reasoner"));
|
||||
let result = model(&mut app, Some("deepseek-v4-flash"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains(&old_model));
|
||||
assert!(msg.contains("deepseek-reasoner"));
|
||||
assert!(msg.contains("deepseek-v4-flash"));
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
assert_eq!(app.model, "deepseek-reasoner");
|
||||
assert_eq!(app.model, "deepseek-v4-flash");
|
||||
assert_eq!(app.last_prompt_tokens, None);
|
||||
assert_eq!(app.last_completion_tokens, None);
|
||||
}
|
||||
@@ -363,21 +363,17 @@ mod tests {
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Invalid model"));
|
||||
assert!(msg.contains("DeepSeek model ID"));
|
||||
assert!(msg.contains("deepseek-chat"));
|
||||
assert!(msg.contains("deepseek-reasoner"));
|
||||
assert!(msg.contains("deepseek-v4-pro"));
|
||||
assert!(msg.contains("deepseek-v4-flash"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_without_args_shows_info() {
|
||||
fn test_model_without_args_opens_picker() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Current model:"));
|
||||
assert!(msg.contains("Common models:"));
|
||||
assert!(msg.contains("any valid DeepSeek model ID"));
|
||||
assert!(result.action.is_none());
|
||||
assert_eq!(result.message, None);
|
||||
assert_eq!(result.action, Some(AppAction::OpenModelPicker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -418,7 +414,7 @@ mod tests {
|
||||
let result = home_dashboard(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("DeepSeek CLI Home Dashboard"));
|
||||
assert!(msg.contains("DeepSeek TUI Home Dashboard"));
|
||||
assert!(msg.contains("Model:"));
|
||||
assert!(msg.contains("Mode:"));
|
||||
assert!(msg.contains("Workspace:"));
|
||||
@@ -444,12 +440,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_mode_tips_for_each_mode() {
|
||||
let modes = [
|
||||
AppMode::Normal,
|
||||
AppMode::Agent,
|
||||
AppMode::Yolo,
|
||||
AppMode::Plan,
|
||||
];
|
||||
let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan];
|
||||
for mode in modes {
|
||||
let mut app = create_test_app();
|
||||
app.mode = mode;
|
||||
@@ -123,10 +123,11 @@ mod tests {
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("/tmp/test-workspace"),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("/tmp/test-skills"),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
@@ -161,10 +161,11 @@ mod tests {
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
@@ -3,11 +3,13 @@
|
||||
//! This module provides a modular command system inspired by Codex-rs.
|
||||
//! Commands are organized by category and dispatched through a central registry.
|
||||
|
||||
mod attachment;
|
||||
mod config;
|
||||
mod core;
|
||||
mod debug;
|
||||
mod init;
|
||||
mod note;
|
||||
mod provider;
|
||||
mod queue;
|
||||
mod review;
|
||||
mod session;
|
||||
@@ -132,6 +134,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "List available models from API",
|
||||
usage: "/models",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "provider",
|
||||
aliases: &[],
|
||||
description: "Switch or view the active LLM backend (deepseek | nvidia-nim)",
|
||||
usage: "/provider [name]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "queue",
|
||||
aliases: &["queued"],
|
||||
@@ -162,6 +170,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "Append note to persistent notes file (.deepseek/notes.md)",
|
||||
usage: "/note <text>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "attach",
|
||||
aliases: &["image", "media"],
|
||||
description: "Attach image/video media; use @path for text files or directories",
|
||||
usage: "/attach <path>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "task",
|
||||
aliases: &["tasks"],
|
||||
@@ -212,12 +226,6 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "Enable YOLO mode (shell + trust + auto-approve)",
|
||||
usage: "/yolo",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "normal",
|
||||
aliases: &[],
|
||||
description: "Switch to normal mode (no autonomous tool flow)",
|
||||
usage: "/normal",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "agent",
|
||||
aliases: &[],
|
||||
@@ -233,8 +241,8 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "trust",
|
||||
aliases: &[],
|
||||
description: "Enable trust mode (access files outside workspace)",
|
||||
usage: "/trust",
|
||||
description: "Manage workspace trust and per-path allowlist (`/trust add <path>`, `/trust list`, `/trust on|off`)",
|
||||
usage: "/trust [on|off|add <path>|remove <path>|list]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "logout",
|
||||
@@ -328,11 +336,13 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"exit" | "quit" | "q" => core::exit(),
|
||||
"model" => core::model(app, arg),
|
||||
"models" => core::models(app),
|
||||
"provider" => provider::provider(app, arg),
|
||||
"queue" | "queued" => queue::queue(app, arg),
|
||||
"subagents" | "agents" => core::subagents(app),
|
||||
"links" | "dashboard" | "api" => core::deepseek_links(),
|
||||
"home" | "stats" | "overview" => core::home_dashboard(app),
|
||||
"note" => note::note(app, arg),
|
||||
"attach" | "image" | "media" => attachment::attach(app, arg),
|
||||
"task" | "tasks" => task::task(app, arg),
|
||||
|
||||
// Session commands
|
||||
@@ -346,10 +356,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"config" => config::show_config(app),
|
||||
"settings" => config::show_settings(app),
|
||||
"yolo" => config::yolo(app),
|
||||
"normal" => config::normal_mode(app),
|
||||
"agent" => config::agent_mode(app),
|
||||
"plan" => config::plan_mode(app),
|
||||
"trust" => config::trust(app),
|
||||
"trust" => config::trust(app, arg),
|
||||
"logout" => config::logout(app),
|
||||
|
||||
// Debug commands
|
||||
@@ -372,6 +381,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"set" => CommandResult::error(
|
||||
"The /set command was retired. Use /config to edit settings and /settings to inspect current values.",
|
||||
),
|
||||
"normal" => config::normal_mode(app),
|
||||
"deepseek" => CommandResult::error(
|
||||
"The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).",
|
||||
),
|
||||
@@ -511,10 +521,11 @@ mod tests {
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
@@ -568,7 +579,7 @@ mod tests {
|
||||
#[test]
|
||||
fn removed_set_and_deepseek_commands_show_migration_hints() {
|
||||
let mut app = create_test_app();
|
||||
let set_result = execute("/set model deepseek-reasoner", &mut app);
|
||||
let set_result = execute("/set model deepseek-v4-pro", &mut app);
|
||||
let set_msg = set_result
|
||||
.message
|
||||
.expect("legacy command should return an error message");
|
||||
@@ -58,10 +58,11 @@ mod tests {
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Provider switching: flip between DeepSeek and NVIDIA NIM at runtime.
|
||||
|
||||
use crate::config::{ApiProvider, normalize_model_name};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Switch or view the current LLM backend.
|
||||
///
|
||||
/// Accepts `<provider> [model]` so you can flip backend and model in one
|
||||
/// shot, e.g. `/provider nim flash` lands you on
|
||||
/// `deepseek-ai/deepseek-v4-flash`. The optional model accepts shorthand
|
||||
/// (`flash`, `pro`, `v4-flash`, `v4-pro`) or any normal DeepSeek model ID.
|
||||
pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let trimmed = args.map(str::trim).filter(|s| !s.is_empty());
|
||||
let Some(args) = trimmed else {
|
||||
return CommandResult::message(format!(
|
||||
"Current provider: {}\n\
|
||||
Active model: {}\n\
|
||||
Available: deepseek, nvidia-nim\n\
|
||||
Usage: /provider <name> [model]\n\
|
||||
Examples: /provider nim flash → NIM v4-flash (recommended)\n\
|
||||
/provider nim pro → NIM v4-pro (currently DEGRADED)\n\
|
||||
/provider deepseek → DeepSeek native, default model\n\
|
||||
Tip: NIM needs NVIDIA_API_KEY (or [providers.nvidia_nim].api_key in config.toml).",
|
||||
app.api_provider.as_str(),
|
||||
app.model
|
||||
));
|
||||
};
|
||||
|
||||
let mut parts = args.split_whitespace();
|
||||
let name = parts.next().unwrap_or("");
|
||||
let model_arg = parts.next();
|
||||
|
||||
let Some(target) = ApiProvider::parse(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim."
|
||||
));
|
||||
};
|
||||
|
||||
let model = match model_arg {
|
||||
None => None,
|
||||
Some(raw) => match normalize_model_name(&expand_model_alias(raw)) {
|
||||
Some(normalized) => Some(normalized),
|
||||
None => {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro."
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if target == app.api_provider && model.is_none() {
|
||||
return CommandResult::message(format!("Already on provider: {}", target.as_str()));
|
||||
}
|
||||
|
||||
CommandResult::action(AppAction::SwitchProvider {
|
||||
provider: target,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_model_alias(name: &str) -> String {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
|
||||
"flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_args_shows_current_provider_and_usage() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, None);
|
||||
let msg = result.message.expect("expected info message");
|
||||
assert!(msg.contains("Current provider:"));
|
||||
assert!(msg.contains("deepseek"));
|
||||
assert!(msg.contains("Available:"));
|
||||
assert!(msg.contains("nvidia-nim"));
|
||||
assert!(msg.contains("/provider nim flash"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_provider_returns_error() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("openai"));
|
||||
let msg = result.message.expect("expected error message");
|
||||
assert!(msg.contains("Unknown provider"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_active_provider_without_model_is_a_noop() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("deepseek"));
|
||||
let msg = result.message.expect("expected message");
|
||||
assert!(msg.contains("Already on provider"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_nim_emits_action_without_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nvidia-nim"));
|
||||
assert!(result.message.is_none());
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model, None);
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nim_flash_shorthand_emits_action_with_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim flash"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nim_pro_shorthand_emits_action_with_model_override() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim pro"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::NvidiaNim);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_active_provider_with_new_model_still_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("deepseek flash"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::Deepseek);
|
||||
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
|
||||
}
|
||||
other => panic!("expected SwitchProvider action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_model_returns_error() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("nim gpt-4"));
|
||||
let msg = result.message.expect("expected error message");
|
||||
assert!(msg.contains("Invalid model"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
@@ -137,10 +137,11 @@ mod tests {
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
@@ -71,10 +71,11 @@ mod tests {
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
@@ -195,10 +195,11 @@ mod tests {
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
@@ -117,10 +117,11 @@ mod tests {
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
@@ -50,10 +50,11 @@ mod tests {
|
||||
fn app() -> App {
|
||||
App::new(
|
||||
TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: false,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 2,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
@@ -14,6 +14,7 @@ use crate::llm_client::LlmClient;
|
||||
use crate::logging;
|
||||
use crate::models::{
|
||||
CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt,
|
||||
context_window_for_model,
|
||||
};
|
||||
|
||||
/// Configuration for conversation compaction behavior.
|
||||
@@ -47,6 +48,50 @@ const SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 240;
|
||||
const SUMMARY_INPUT_MAX_CHARS: usize = 24_000;
|
||||
const SUMMARY_INPUT_HEAD_CHARS: usize = 14_000;
|
||||
const SUMMARY_INPUT_TAIL_CHARS: usize = 6_000;
|
||||
const LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS: usize = 2_000;
|
||||
const LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 4_000;
|
||||
const LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS: usize = 120_000;
|
||||
const LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS: usize = 72_000;
|
||||
const LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS: usize = 36_000;
|
||||
const LARGE_CONTEXT_SUMMARY_MAX_TOKENS: u32 = 2_048;
|
||||
const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SummaryInputLimits {
|
||||
text_snippet_chars: usize,
|
||||
tool_result_snippet_chars: usize,
|
||||
input_max_chars: usize,
|
||||
input_head_chars: usize,
|
||||
input_tail_chars: usize,
|
||||
max_tokens: u32,
|
||||
word_limit: usize,
|
||||
}
|
||||
|
||||
fn summary_input_limits_for_model(model: &str) -> SummaryInputLimits {
|
||||
let is_large_context =
|
||||
context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS);
|
||||
if is_large_context {
|
||||
SummaryInputLimits {
|
||||
text_snippet_chars: LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS,
|
||||
tool_result_snippet_chars: LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
|
||||
input_max_chars: LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS,
|
||||
input_head_chars: LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS,
|
||||
input_tail_chars: LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS,
|
||||
max_tokens: LARGE_CONTEXT_SUMMARY_MAX_TOKENS,
|
||||
word_limit: 900,
|
||||
}
|
||||
} else {
|
||||
SummaryInputLimits {
|
||||
text_snippet_chars: SUMMARY_TEXT_SNIPPET_CHARS,
|
||||
tool_result_snippet_chars: SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
|
||||
input_max_chars: SUMMARY_INPUT_MAX_CHARS,
|
||||
input_head_chars: SUMMARY_INPUT_HEAD_CHARS,
|
||||
input_tail_chars: SUMMARY_INPUT_TAIL_CHARS,
|
||||
max_tokens: 1_024,
|
||||
word_limit: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct CompactionPlan {
|
||||
@@ -456,13 +501,16 @@ fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<u
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_tokens_for_message(message: &Message) -> usize {
|
||||
fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
|
||||
message
|
||||
.content
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
ContentBlock::Text { text, .. } => text.len() / 4,
|
||||
ContentBlock::Thinking { thinking } => thinking.len() / 4,
|
||||
// Historical reasoning blocks are UI/session metadata for DeepSeek.
|
||||
// Only current-turn tool-call reasoning is sent back to the API.
|
||||
ContentBlock::Thinking { thinking } if include_thinking => thinking.len() / 4,
|
||||
ContentBlock::Thinking { .. } => 0,
|
||||
ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
|
||||
.map(|s| s.len() / 4)
|
||||
.unwrap_or(100),
|
||||
@@ -475,8 +523,20 @@ fn estimate_tokens_for_message(message: &Message) -> usize {
|
||||
}
|
||||
|
||||
pub fn estimate_tokens(messages: &[Message]) -> usize {
|
||||
// Rough estimate: ~4 chars per token
|
||||
messages.iter().map(estimate_tokens_for_message).sum()
|
||||
// Rough estimate: ~4 chars per token. DeepSeek thinking-mode rule: any
|
||||
// assistant message with tool_calls keeps its reasoning_content forever
|
||||
// (replayed in all subsequent requests). Final text-only answers drop it.
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| estimate_tokens_for_message(message, message_has_tool_use(message)))
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn message_has_tool_use(message: &Message) -> bool {
|
||||
message
|
||||
.content
|
||||
.iter()
|
||||
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
|
||||
}
|
||||
|
||||
fn estimate_text_tokens_conservative(text: &str) -> usize {
|
||||
@@ -529,14 +589,14 @@ pub fn should_compact(
|
||||
let pinned_tokens: usize = plan
|
||||
.pinned_indices
|
||||
.iter()
|
||||
.map(|&idx| estimate_tokens_for_message(&messages[idx]))
|
||||
.map(|&idx| estimate_tokens_for_message(&messages[idx], false))
|
||||
.sum();
|
||||
let pinned_count = plan.pinned_indices.len();
|
||||
|
||||
let token_estimate: usize = plan
|
||||
.summarize_indices
|
||||
.iter()
|
||||
.map(|&idx| estimate_tokens_for_message(&messages[idx]))
|
||||
.map(|&idx| estimate_tokens_for_message(&messages[idx], false))
|
||||
.sum();
|
||||
let message_count = plan.summarize_indices.len();
|
||||
|
||||
@@ -755,6 +815,7 @@ async fn create_summary(
|
||||
messages: &[Message],
|
||||
model: &str,
|
||||
) -> Result<String> {
|
||||
let limits = summary_input_limits_for_model(model);
|
||||
// Format messages for summarization
|
||||
let mut conversation_text = String::new();
|
||||
for msg in messages {
|
||||
@@ -766,14 +827,14 @@ async fn create_summary(
|
||||
for block in &msg.content {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
let snippet = truncate_chars(text, SUMMARY_TEXT_SNIPPET_CHARS);
|
||||
let snippet = truncate_chars(text, limits.text_snippet_chars);
|
||||
let _ = write!(conversation_text, "{role}: {snippet}\n\n");
|
||||
}
|
||||
ContentBlock::ToolUse { name, .. } => {
|
||||
let _ = write!(conversation_text, "{role}: [Used tool: {name}]\n\n");
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
let snippet = truncate_chars(content, SUMMARY_TOOL_RESULT_SNIPPET_CHARS);
|
||||
let snippet = truncate_chars(content, limits.tool_result_snippet_chars);
|
||||
let _ = write!(conversation_text, "Tool result: {}\n\n", snippet);
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {
|
||||
@@ -787,9 +848,9 @@ async fn create_summary(
|
||||
}
|
||||
|
||||
let conversation_chars = conversation_text.chars().count();
|
||||
if conversation_chars > SUMMARY_INPUT_MAX_CHARS {
|
||||
let head = truncate_chars(&conversation_text, SUMMARY_INPUT_HEAD_CHARS).to_string();
|
||||
let tail = tail_chars(&conversation_text, SUMMARY_INPUT_TAIL_CHARS);
|
||||
if conversation_chars > limits.input_max_chars {
|
||||
let head = truncate_chars(&conversation_text, limits.input_head_chars).to_string();
|
||||
let tail = tail_chars(&conversation_text, limits.input_tail_chars);
|
||||
let omitted = conversation_chars
|
||||
.saturating_sub(head.chars().count())
|
||||
.saturating_sub(tail.chars().count());
|
||||
@@ -804,14 +865,16 @@ async fn create_summary(
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!(
|
||||
"Summarize the following conversation in a concise but comprehensive way. \
|
||||
Preserve key information, decisions made, and any important context. \
|
||||
Tool outputs may be abbreviated. \
|
||||
Keep it under 500 words.\n\n---\n\n{conversation_text}"
|
||||
Preserve key information, decisions made, exact file paths, commands, \
|
||||
errors, and tool-result facts needed to continue the work. \
|
||||
Tool outputs may be abbreviated only when they are repetitive. \
|
||||
Keep it under {} words.\n\n---\n\n{conversation_text}",
|
||||
limits.word_limit
|
||||
),
|
||||
cache_control: None,
|
||||
}],
|
||||
}],
|
||||
max_tokens: 1024,
|
||||
max_tokens: limits.max_tokens,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"You are a helpful assistant that creates concise conversation summaries.".to_string(),
|
||||
)),
|
||||
@@ -819,6 +882,7 @@ async fn create_summary(
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: None,
|
||||
stream: Some(false),
|
||||
temperature: Some(0.3),
|
||||
top_p: None,
|
||||
@@ -859,15 +923,14 @@ fn extract_workflow_context(messages: &[Message], workspace: Option<&Path>) -> S
|
||||
files_touched.push(path);
|
||||
}
|
||||
}
|
||||
ContentBlock::Text { text, .. } => {
|
||||
ContentBlock::Text { text, .. }
|
||||
// Look for task/todo mentions
|
||||
if text.contains("TODO") || text.contains("task") || text.contains("need to") {
|
||||
if (text.contains("TODO") || text.contains("task") || text.contains("need to")) => {
|
||||
let task = truncate_chars(text, 200).to_string();
|
||||
if !tasks_identified.contains(&task) {
|
||||
tasks_identified.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1033,6 +1096,16 @@ mod tests {
|
||||
assert!(!is_transient_error(&validation_err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_limits_expand_for_v4_context() {
|
||||
let legacy = summary_input_limits_for_model("deepseek-v3.2-128k");
|
||||
let v4 = summary_input_limits_for_model("deepseek-v4-pro");
|
||||
|
||||
assert!(v4.input_max_chars > legacy.input_max_chars);
|
||||
assert!(v4.tool_result_snippet_chars > legacy.tool_result_snippet_chars);
|
||||
assert!(v4.max_tokens > legacy.max_tokens);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_tokens_empty_messages() {
|
||||
let messages: Vec<Message> = vec![];
|
||||
@@ -1052,6 +1125,81 @@ mod tests {
|
||||
assert!(tokens > 0 && tokens < 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_tokens_counts_tool_round_thinking_across_turns() {
|
||||
// Per DeepSeek thinking-mode rules, any assistant message that
|
||||
// performed a tool call keeps its reasoning_content in the request
|
||||
// forever, including across new user turns. Token estimates must
|
||||
// count those bytes.
|
||||
let thinking = "reasoning ".repeat(800);
|
||||
let current_messages = vec![
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Use a tool".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: serde_json::json!({"path": "Cargo.toml"}),
|
||||
caller: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: "manifest".to_string(),
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
},
|
||||
];
|
||||
let historical_messages = {
|
||||
let mut messages = current_messages.clone();
|
||||
messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Done.".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Next question.".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
messages
|
||||
};
|
||||
let completed_messages = {
|
||||
let mut messages = current_messages.clone();
|
||||
messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Done.".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
messages
|
||||
};
|
||||
|
||||
let lower_bound = thinking.len() / 5;
|
||||
assert!(estimate_tokens(¤t_messages) > lower_bound);
|
||||
assert!(estimate_tokens(&completed_messages) > lower_bound);
|
||||
assert!(estimate_tokens(&historical_messages) > lower_bound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_compact_respects_enabled_flag() {
|
||||
let config = CompactionConfig {
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Configuration loading and defaults for deepseek-cli.
|
||||
//! Configuration loading and defaults for DeepSeek TUI.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
@@ -15,16 +15,55 @@ use crate::hooks::HooksConfig;
|
||||
|
||||
pub const DEFAULT_MAX_SUBAGENTS: usize = 5;
|
||||
pub const MAX_SUBAGENTS: usize = 20;
|
||||
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-reasoner";
|
||||
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro";
|
||||
pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
|
||||
pub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
|
||||
pub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
|
||||
const API_KEYRING_SENTINEL: &str = "__KEYRING__";
|
||||
pub const COMMON_DEEPSEEK_MODELS: &[&str] = &["deepseek-chat", "deepseek-reasoner"];
|
||||
pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-ai/deepseek-v4-pro",
|
||||
"deepseek-ai/deepseek-v4-flash",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApiProvider {
|
||||
Deepseek,
|
||||
NvidiaNim,
|
||||
}
|
||||
|
||||
impl ApiProvider {
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek" | "deep-seek" => Some(Self::Deepseek),
|
||||
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Deepseek => "deepseek",
|
||||
Self::NvidiaNim => "nvidia-nim",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalize common model aliases to stable DeepSeek IDs.
|
||||
///
|
||||
/// Legacy `deepseek-chat` / `deepseek-reasoner` remain silent aliases for the
|
||||
/// current fast V4 model.
|
||||
#[must_use]
|
||||
pub fn canonical_model_name(model: &str) -> Option<&'static str> {
|
||||
match model.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek-chat" | "deepseek-v3" | "deepseek-v3.2" => Some("deepseek-chat"),
|
||||
"deepseek-reasoner" | "deepseek-r1" => Some("deepseek-reasoner"),
|
||||
"deepseek-v4-pro" | "deepseek-v4pro" => Some("deepseek-v4-pro"),
|
||||
"deepseek-v4-flash" | "deepseek-v4flash" => Some("deepseek-v4-flash"),
|
||||
"deepseek-chat" | "deepseek-reasoner" | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2" => {
|
||||
Some("deepseek-v4-flash")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -49,7 +88,7 @@ pub fn normalize_model_name(model: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
if normalized.chars().all(|ch| {
|
||||
ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_' | '.' | ':')
|
||||
ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_' | '.' | ':' | '/')
|
||||
}) {
|
||||
return Some(normalized);
|
||||
}
|
||||
@@ -73,6 +112,7 @@ pub struct RetryConfig {
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct TuiConfig {
|
||||
pub alternate_screen: Option<String>,
|
||||
pub mouse_capture: Option<bool>,
|
||||
}
|
||||
|
||||
/// Resolved retry policy with defaults applied.
|
||||
@@ -120,9 +160,13 @@ impl RetryPolicy {
|
||||
/// Resolved CLI configuration, including defaults and environment overrides.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
pub provider: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub default_text_model: Option<String>,
|
||||
/// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
|
||||
/// Defaults to `"max"` at runtime if unset.
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub tools_file: Option<String>,
|
||||
pub skills_dir: Option<String>,
|
||||
pub mcp_config_path: Option<String>,
|
||||
@@ -144,6 +188,25 @@ pub struct Config {
|
||||
/// Lifecycle hooks configuration
|
||||
#[serde(default)]
|
||||
pub hooks: Option<HooksConfig>,
|
||||
|
||||
/// Provider-specific credentials and defaults shared with the `deepseek` facade.
|
||||
#[serde(default)]
|
||||
pub providers: Option<ProvidersConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ProvidersConfig {
|
||||
#[serde(default)]
|
||||
pub deepseek: ProviderConfig,
|
||||
#[serde(default)]
|
||||
pub nvidia_nim: ProviderConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
@@ -199,6 +262,11 @@ impl Config {
|
||||
|
||||
/// Validate that critical config fields are present.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(provider) = self.provider.as_deref()
|
||||
&& ApiProvider::parse(provider).is_none()
|
||||
{
|
||||
anyhow::bail!("Invalid provider '{provider}': expected deepseek or nvidia-nim.");
|
||||
}
|
||||
if let Some(ref key) = self.api_key
|
||||
&& key.trim().is_empty()
|
||||
{
|
||||
@@ -215,7 +283,7 @@ impl Config {
|
||||
&& normalize_model_name(model).is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Invalid default_text_model '{model}': expected a DeepSeek model ID (for example: deepseek-chat, deepseek-reasoner, deepseek-v4)."
|
||||
"Invalid default_text_model '{model}': expected a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro)."
|
||||
);
|
||||
}
|
||||
if let Some(policy) = self.approval_policy.as_deref() {
|
||||
@@ -283,26 +351,112 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the `DeepSeek` base URL (normalized).
|
||||
#[must_use]
|
||||
pub fn api_provider(&self) -> ApiProvider {
|
||||
self.provider
|
||||
.as_deref()
|
||||
.and_then(ApiProvider::parse)
|
||||
.unwrap_or_else(|| {
|
||||
self.base_url
|
||||
.as_deref()
|
||||
.filter(|base| base.contains("integrate.api.nvidia.com"))
|
||||
.map(|_| ApiProvider::NvidiaNim)
|
||||
.unwrap_or(ApiProvider::Deepseek)
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
|
||||
let providers = self.providers.as_ref()?;
|
||||
Some(match provider {
|
||||
ApiProvider::Deepseek => &providers.deepseek,
|
||||
ApiProvider::NvidiaNim => &providers.nvidia_nim,
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_config(&self) -> Option<&ProviderConfig> {
|
||||
self.provider_config_for(self.api_provider())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_model(&self) -> String {
|
||||
let provider = self.api_provider();
|
||||
if let Some(model) = self
|
||||
.provider_config()
|
||||
.and_then(|provider| provider.model.as_deref())
|
||||
&& let Some(normalized) = normalize_model_for_provider(provider, model)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
if let Some(model) = self.default_text_model.as_deref()
|
||||
&& let Some(normalized) = normalize_model_name(model)
|
||||
{
|
||||
return model_for_provider(provider, normalized);
|
||||
}
|
||||
|
||||
match provider {
|
||||
ApiProvider::Deepseek => DEFAULT_TEXT_MODEL,
|
||||
ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Return the configured API base URL (normalized).
|
||||
#[must_use]
|
||||
pub fn deepseek_base_url(&self) -> String {
|
||||
let base = self
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "https://api.deepseek.com".to_string());
|
||||
let provider = self.api_provider();
|
||||
let provider_base = self
|
||||
.provider_config_for(provider)
|
||||
.and_then(|provider| provider.base_url.clone());
|
||||
let root_base = match provider {
|
||||
ApiProvider::Deepseek => self.base_url.clone(),
|
||||
ApiProvider::NvidiaNim => self
|
||||
.base_url
|
||||
.as_ref()
|
||||
.filter(|base| base.contains("integrate.api.nvidia.com"))
|
||||
.cloned(),
|
||||
};
|
||||
let base = provider_base.or(root_base).unwrap_or_else(|| {
|
||||
match provider {
|
||||
ApiProvider::Deepseek => "https://api.deepseek.com",
|
||||
ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
normalize_base_url(&base)
|
||||
}
|
||||
|
||||
/// Read the `DeepSeek` API key from config/environment.
|
||||
/// Read the API key from config/environment.
|
||||
pub fn deepseek_api_key(&self) -> Result<String> {
|
||||
// First check environment variable (highest priority)
|
||||
if let Ok(key) = std::env::var("DEEPSEEK_API_KEY")
|
||||
&& !key.trim().is_empty()
|
||||
{
|
||||
return Ok(key);
|
||||
let provider = self.api_provider();
|
||||
|
||||
match provider {
|
||||
ApiProvider::Deepseek => {
|
||||
if let Ok(key) = std::env::var("DEEPSEEK_API_KEY")
|
||||
&& !key.trim().is_empty()
|
||||
{
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
ApiProvider::NvidiaNim => {
|
||||
for name in ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] {
|
||||
if let Ok(key) = std::env::var(name)
|
||||
&& !key.trim().is_empty()
|
||||
{
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then check config file
|
||||
if let Some(configured) = self
|
||||
.provider_config_for(provider)
|
||||
.and_then(|provider| provider.api_key.clone())
|
||||
&& !configured.trim().is_empty()
|
||||
{
|
||||
return Ok(configured);
|
||||
}
|
||||
|
||||
if let Some(configured) = self.api_key.clone()
|
||||
&& !configured.trim().is_empty()
|
||||
&& configured != API_KEYRING_SENTINEL
|
||||
@@ -310,13 +464,18 @@ impl Config {
|
||||
return Ok(configured);
|
||||
}
|
||||
|
||||
// Provide helpful error message with alternatives
|
||||
anyhow::bail!(
|
||||
"DeepSeek API key not found. Set it using one of these methods:\n\
|
||||
1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\
|
||||
2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\
|
||||
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml"
|
||||
)
|
||||
match provider {
|
||||
ApiProvider::Deepseek => anyhow::bail!(
|
||||
"DeepSeek API key not found. Set it using one of these methods:\n\
|
||||
1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\
|
||||
2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\
|
||||
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml"
|
||||
),
|
||||
ApiProvider::NvidiaNim => anyhow::bail!(
|
||||
"NVIDIA NIM API key not found. Set NVIDIA_API_KEY, NVIDIA_NIM_API_KEY, \
|
||||
or save api_key in ~/.deepseek/config.toml with provider = \"nvidia-nim\"."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the skills directory path.
|
||||
@@ -373,6 +532,12 @@ impl Config {
|
||||
.clamp(1, MAX_SUBAGENTS)
|
||||
}
|
||||
|
||||
/// Return the configured DeepSeek reasoning-effort tier, if any.
|
||||
#[must_use]
|
||||
pub fn reasoning_effort(&self) -> Option<&str> {
|
||||
self.reasoning_effort.as_deref()
|
||||
}
|
||||
|
||||
/// Get hooks configuration, returning default if not configured.
|
||||
pub fn hooks_config(&self) -> HooksConfig {
|
||||
self.hooks.clone().unwrap_or_default()
|
||||
@@ -525,7 +690,7 @@ fn default_requirements_path() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_path(path: &str) -> PathBuf {
|
||||
pub(crate) fn expand_path(path: &str) -> PathBuf {
|
||||
if let Some(stripped) = path.strip_prefix('~')
|
||||
&& (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\'))
|
||||
&& let Some(mut home) = effective_home_dir()
|
||||
@@ -560,11 +725,45 @@ fn default_memory_path() -> Option<PathBuf> {
|
||||
// === Environment Overrides ===
|
||||
|
||||
fn apply_env_overrides(config: &mut Config) {
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_API_KEY") {
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
|
||||
config.provider = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_API_KEY")
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
config.api_key = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_BASE_URL") {
|
||||
config.base_url = Some(value);
|
||||
if matches!(config.api_provider(), ApiProvider::NvidiaNim) {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.nvidia_nim
|
||||
.base_url = Some(value);
|
||||
} else {
|
||||
config.base_url = Some(value);
|
||||
}
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::NvidiaNim)
|
||||
&& let Ok(value) = std::env::var("NVIDIA_NIM_BASE_URL")
|
||||
.or_else(|_| std::env::var("NIM_BASE_URL"))
|
||||
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
|
||||
{
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.nvidia_nim
|
||||
.base_url = Some(value);
|
||||
}
|
||||
if let Ok(value) =
|
||||
std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
|
||||
{
|
||||
config.default_text_model = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::NvidiaNim)
|
||||
&& let Ok(value) = std::env::var("NVIDIA_NIM_MODEL")
|
||||
{
|
||||
config.default_text_model = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_SKILLS_DIR") {
|
||||
config.skills_dir = Some(value);
|
||||
@@ -701,10 +900,35 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
|
||||
fn normalize_model_config(config: &mut Config) {
|
||||
if let Some(model) = config.default_text_model.as_deref()
|
||||
&& let Some(normalized) = normalize_model_name(model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)
|
||||
{
|
||||
config.default_text_model = Some(normalized);
|
||||
}
|
||||
|
||||
if let Some(providers) = config.providers.as_mut() {
|
||||
if let Some(model) = providers.deepseek.model.as_deref()
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)
|
||||
{
|
||||
providers.deepseek.model = Some(normalized);
|
||||
}
|
||||
if let Some(model) = providers.nvidia_nim.model.as_deref()
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)
|
||||
{
|
||||
providers.nvidia_nim.model = Some(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
|
||||
normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))
|
||||
}
|
||||
|
||||
fn model_for_provider(provider: ApiProvider, normalized: String) -> String {
|
||||
match (provider, normalized.as_str()) {
|
||||
(ApiProvider::NvidiaNim, "deepseek-v4-pro") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
|
||||
(ApiProvider::NvidiaNim, "deepseek-v4-flash") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
|
||||
_ => normalized,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_base_url(base: &str) -> String {
|
||||
@@ -750,9 +974,11 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
|
||||
|
||||
fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
Config {
|
||||
provider: override_cfg.provider.or(base.provider),
|
||||
api_key: override_cfg.api_key.or(base.api_key),
|
||||
base_url: override_cfg.base_url.or(base.base_url),
|
||||
default_text_model: override_cfg.default_text_model.or(base.default_text_model),
|
||||
reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),
|
||||
tools_file: override_cfg.tools_file.or(base.tools_file),
|
||||
skills_dir: override_cfg.skills_dir.or(base.skills_dir),
|
||||
mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),
|
||||
@@ -770,10 +996,34 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
capacity: override_cfg.capacity.or(base.capacity),
|
||||
tui: override_cfg.tui.or(base.tui),
|
||||
hooks: override_cfg.hooks.or(base.hooks),
|
||||
providers: merge_providers(base.providers, override_cfg.providers),
|
||||
features: merge_features(base.features, override_cfg.features),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {
|
||||
ProviderConfig {
|
||||
api_key: override_cfg.api_key.or(base.api_key),
|
||||
base_url: override_cfg.base_url.or(base.base_url),
|
||||
model: override_cfg.model.or(base.model),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_providers(
|
||||
base: Option<ProvidersConfig>,
|
||||
override_cfg: Option<ProvidersConfig>,
|
||||
) -> Option<ProvidersConfig> {
|
||||
match (base, override_cfg) {
|
||||
(None, None) => None,
|
||||
(Some(base), None) => Some(base),
|
||||
(None, Some(override_cfg)) => Some(override_cfg),
|
||||
(Some(base), Some(override_cfg)) => Some(ProvidersConfig {
|
||||
deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),
|
||||
nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_single_config_file(path: &Path) -> Result<Config> {
|
||||
let contents = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
|
||||
@@ -915,7 +1165,7 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
|
||||
} else {
|
||||
// Create new minimal config
|
||||
format!(
|
||||
r#"# DeepSeek CLI Configuration
|
||||
r#"# DeepSeek TUI Configuration
|
||||
# Get your API key from https://platform.deepseek.com
|
||||
# Or set DEEPSEEK_API_KEY environment variable
|
||||
|
||||
@@ -926,6 +1176,11 @@ api_key = "{key_to_write}"
|
||||
|
||||
# Default model
|
||||
default_text_model = "{default_model}"
|
||||
|
||||
# Thinking mode (DeepSeek V4 reasoning effort):
|
||||
# "off" | "low" | "medium" | "high" | "max"
|
||||
# Shift+Tab in the TUI cycles between off / high / max.
|
||||
reasoning_effort = "max"
|
||||
"#,
|
||||
default_model = DEFAULT_TEXT_MODEL
|
||||
)
|
||||
@@ -1006,7 +1261,17 @@ mod tests {
|
||||
home: Option<OsString>,
|
||||
userprofile: Option<OsString>,
|
||||
deepseek_config_path: Option<OsString>,
|
||||
deepseek_provider: Option<OsString>,
|
||||
deepseek_api_key: Option<OsString>,
|
||||
deepseek_base_url: Option<OsString>,
|
||||
deepseek_model: Option<OsString>,
|
||||
deepseek_default_text_model: Option<OsString>,
|
||||
nvidia_api_key: Option<OsString>,
|
||||
nvidia_nim_api_key: Option<OsString>,
|
||||
nim_base_url: Option<OsString>,
|
||||
nvidia_base_url: Option<OsString>,
|
||||
nvidia_nim_base_url: Option<OsString>,
|
||||
nvidia_nim_model: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
@@ -1017,19 +1282,49 @@ mod tests {
|
||||
let home_prev = env::var_os("HOME");
|
||||
let userprofile_prev = env::var_os("USERPROFILE");
|
||||
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
let deepseek_provider_prev = env::var_os("DEEPSEEK_PROVIDER");
|
||||
let api_key_prev = env::var_os("DEEPSEEK_API_KEY");
|
||||
let base_url_prev = env::var_os("DEEPSEEK_BASE_URL");
|
||||
let model_prev = env::var_os("DEEPSEEK_MODEL");
|
||||
let default_text_model_prev = env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL");
|
||||
let nvidia_api_key_prev = env::var_os("NVIDIA_API_KEY");
|
||||
let nvidia_nim_api_key_prev = env::var_os("NVIDIA_NIM_API_KEY");
|
||||
let nim_base_url_prev = env::var_os("NIM_BASE_URL");
|
||||
let nvidia_base_url_prev = env::var_os("NVIDIA_BASE_URL");
|
||||
let nvidia_nim_base_url_prev = env::var_os("NVIDIA_NIM_BASE_URL");
|
||||
let nvidia_nim_model_prev = env::var_os("NVIDIA_NIM_MODEL");
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", &home_str);
|
||||
env::set_var("USERPROFILE", &home_str);
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
|
||||
env::remove_var("DEEPSEEK_PROVIDER");
|
||||
env::remove_var("DEEPSEEK_API_KEY");
|
||||
env::remove_var("DEEPSEEK_BASE_URL");
|
||||
env::remove_var("DEEPSEEK_MODEL");
|
||||
env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
|
||||
env::remove_var("NVIDIA_API_KEY");
|
||||
env::remove_var("NVIDIA_NIM_API_KEY");
|
||||
env::remove_var("NIM_BASE_URL");
|
||||
env::remove_var("NVIDIA_BASE_URL");
|
||||
env::remove_var("NVIDIA_NIM_BASE_URL");
|
||||
env::remove_var("NVIDIA_NIM_MODEL");
|
||||
}
|
||||
Self {
|
||||
home: home_prev,
|
||||
userprofile: userprofile_prev,
|
||||
deepseek_config_path: deepseek_config_prev,
|
||||
deepseek_provider: deepseek_provider_prev,
|
||||
deepseek_api_key: api_key_prev,
|
||||
deepseek_base_url: base_url_prev,
|
||||
deepseek_model: model_prev,
|
||||
deepseek_default_text_model: default_text_model_prev,
|
||||
nvidia_api_key: nvidia_api_key_prev,
|
||||
nvidia_nim_api_key: nvidia_nim_api_key_prev,
|
||||
nim_base_url: nim_base_url_prev,
|
||||
nvidia_base_url: nvidia_base_url_prev,
|
||||
nvidia_nim_base_url: nvidia_nim_base_url_prev,
|
||||
nvidia_nim_model: nvidia_nim_model_prev,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1041,7 +1336,20 @@ mod tests {
|
||||
Self::restore_var("HOME", self.home.take());
|
||||
Self::restore_var("USERPROFILE", self.userprofile.take());
|
||||
Self::restore_var("DEEPSEEK_CONFIG_PATH", self.deepseek_config_path.take());
|
||||
Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
|
||||
Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
|
||||
Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
|
||||
Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
|
||||
Self::restore_var(
|
||||
"DEEPSEEK_DEFAULT_TEXT_MODEL",
|
||||
self.deepseek_default_text_model.take(),
|
||||
);
|
||||
Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
|
||||
Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
|
||||
Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
|
||||
Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
|
||||
Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
|
||||
Self::restore_var("NVIDIA_NIM_MODEL", self.nvidia_nim_model.take());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1068,7 +1376,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-test-{}-{}",
|
||||
"deepseek-tui-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1092,7 +1400,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-tilde-test-{}-{}",
|
||||
"deepseek-tui-tilde-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1121,7 +1429,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-load-tilde-test-{}-{}",
|
||||
"deepseek-tui-load-tilde-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1150,7 +1458,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-load-fallback-test-{}-{}",
|
||||
"deepseek-tui-load-fallback-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1209,7 +1517,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-api-key-test-{}-{}",
|
||||
"deepseek-tui-api-key-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
@@ -1248,20 +1556,61 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-empty-key-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
// Simulate a fresh user who copied .env.example to .env without
|
||||
// filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_API_KEY", "");
|
||||
}
|
||||
|
||||
let mut config = Config {
|
||||
api_key: Some("from-config-file".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
apply_env_overrides(&mut config);
|
||||
|
||||
assert_eq!(config.api_key.as_deref(), Some("from-config-file"));
|
||||
config.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_name_handles_aliases_and_future_ids() {
|
||||
assert_eq!(
|
||||
normalize_model_name("deepseek-v3.2").as_deref(),
|
||||
Some("deepseek-chat")
|
||||
Some("deepseek-v4-flash")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_name("deepseek-r1").as_deref(),
|
||||
Some("deepseek-reasoner")
|
||||
Some("deepseek-v4-flash")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_name("DeepSeek-V4").as_deref(),
|
||||
Some("deepseek-v4")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_name("deepseek-ai/deepseek-v4-pro").as_deref(),
|
||||
Some("deepseek-ai/deepseek-v4-pro")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_model_name("deepseek-ai/deepseek-v4-flash").as_deref(),
|
||||
Some("deepseek-ai/deepseek-v4-flash")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1280,4 +1629,213 @@ mod tests {
|
||||
config.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_model_env_overrides_default_text_model() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-model-env-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_MODEL", "deepseek-chat");
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(
|
||||
config.default_text_model.as_deref(),
|
||||
Some("deepseek-v4-flash")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {
|
||||
let config = Config {
|
||||
provider: Some("nvidia-nim".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
config.validate()?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
|
||||
assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
|
||||
assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-nim-model-alias-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let config_path = temp_root.join(".deepseek").join("config.toml");
|
||||
ensure_parent_dir(&config_path)?;
|
||||
fs::write(
|
||||
&config_path,
|
||||
"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-pro\"\napi_key = \"nim-key\"\n",
|
||||
)?;
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
|
||||
assert_eq!(
|
||||
config.default_text_model.as_deref(),
|
||||
Some(DEFAULT_NVIDIA_NIM_MODEL)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {
|
||||
let config = Config {
|
||||
provider: Some("nvidia-nim".to_string()),
|
||||
default_text_model: Some("deepseek-v4-flash".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
config.validate()?;
|
||||
assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-nim-env-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("NVIDIA_API_KEY", "nim-env-key");
|
||||
env::set_var("NVIDIA_NIM_MODEL", "deepseek-ai/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
|
||||
assert_eq!(config.deepseek_api_key()?, "nim-env-key");
|
||||
assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-nim-base-url-alias-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
|
||||
assert_eq!(config.deepseek_base_url(), "https://short-nim.example/v1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-nim-forwarded-base-url-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
|
||||
env::set_var("DEEPSEEK_BASE_URL", "https://forwarded-nim.example/v1");
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
|
||||
assert_eq!(
|
||||
config.deepseek_base_url(),
|
||||
"https://forwarded-nim.example/v1"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_nim_reads_facade_provider_table() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-nim-provider-table-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let config_path = temp_root.join(".deepseek").join("config.toml");
|
||||
ensure_parent_dir(&config_path)?;
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"provider = "nvidia-nim"
|
||||
default_text_model = "deepseek-v4-flash"
|
||||
|
||||
[providers.nvidia_nim]
|
||||
api_key = "nim-table-key"
|
||||
base_url = "https://nim-table.example/v1"
|
||||
model = "deepseek-v4-pro"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
|
||||
assert_eq!(config.deepseek_api_key()?, "nim-table-key");
|
||||
assert_eq!(config.deepseek_base_url(), "https://nim-table.example/v1");
|
||||
assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,149 @@
|
||||
//! Plain-language session coherence state derived from capacity events.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::capacity::{GuardrailAction, RiskBand};
|
||||
|
||||
/// User-facing coherence ladder for session health.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CoherenceState {
|
||||
#[default]
|
||||
Healthy,
|
||||
GettingCrowded,
|
||||
RefreshingContext,
|
||||
VerifyingRecentWork,
|
||||
ResettingPlan,
|
||||
}
|
||||
|
||||
impl CoherenceState {
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Healthy => "healthy",
|
||||
Self::GettingCrowded => "getting crowded",
|
||||
Self::RefreshingContext => "refreshing context",
|
||||
Self::VerifyingRecentWork => "verifying recent work",
|
||||
Self::ResettingPlan => "resetting plan",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Healthy => "The session is stable and focused.",
|
||||
Self::GettingCrowded => "The session is approaching context pressure.",
|
||||
Self::RefreshingContext => "The engine is refreshing context before continuing.",
|
||||
Self::VerifyingRecentWork => {
|
||||
"The engine is checking recent tool results before continuing."
|
||||
}
|
||||
Self::ResettingPlan => {
|
||||
"The engine is rebuilding from canonical context and replanning."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic input to the coherence reducer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CoherenceSignal {
|
||||
CapacityDecision {
|
||||
risk_band: RiskBand,
|
||||
action: GuardrailAction,
|
||||
cooldown_blocked: bool,
|
||||
},
|
||||
CapacityIntervention {
|
||||
action: GuardrailAction,
|
||||
},
|
||||
CompactionStarted,
|
||||
CompactionCompleted,
|
||||
CompactionFailed,
|
||||
}
|
||||
|
||||
/// Pure transition function for the plain-language coherence ladder.
|
||||
#[must_use]
|
||||
pub fn next_coherence_state(current: CoherenceState, signal: CoherenceSignal) -> CoherenceState {
|
||||
match signal {
|
||||
CoherenceSignal::CompactionStarted => CoherenceState::RefreshingContext,
|
||||
CoherenceSignal::CompactionCompleted => CoherenceState::Healthy,
|
||||
CoherenceSignal::CompactionFailed => CoherenceState::GettingCrowded,
|
||||
CoherenceSignal::CapacityIntervention { action }
|
||||
| CoherenceSignal::CapacityDecision { action, .. } => match action {
|
||||
GuardrailAction::NoIntervention => match signal {
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band,
|
||||
cooldown_blocked,
|
||||
..
|
||||
} => {
|
||||
if cooldown_blocked {
|
||||
return current;
|
||||
}
|
||||
match risk_band {
|
||||
RiskBand::Low => CoherenceState::Healthy,
|
||||
RiskBand::Medium | RiskBand::High => CoherenceState::GettingCrowded,
|
||||
}
|
||||
}
|
||||
_ => current,
|
||||
},
|
||||
GuardrailAction::TargetedContextRefresh => CoherenceState::RefreshingContext,
|
||||
GuardrailAction::VerifyWithToolReplay => CoherenceState::VerifyingRecentWork,
|
||||
GuardrailAction::VerifyAndReplan => CoherenceState::ResettingPlan,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthetic_capacity_event_log_drives_plain_language_ladder() {
|
||||
let log = [
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Low,
|
||||
action: GuardrailAction::NoIntervention,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Medium,
|
||||
action: GuardrailAction::NoIntervention,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::Medium,
|
||||
action: GuardrailAction::TargetedContextRefresh,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CompactionCompleted,
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::High,
|
||||
action: GuardrailAction::VerifyWithToolReplay,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: RiskBand::High,
|
||||
action: GuardrailAction::VerifyAndReplan,
|
||||
cooldown_blocked: false,
|
||||
},
|
||||
];
|
||||
|
||||
let mut state = CoherenceState::Healthy;
|
||||
let mut states = Vec::new();
|
||||
for signal in log {
|
||||
state = next_coherence_state(state, signal);
|
||||
states.push(state);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
states,
|
||||
vec![
|
||||
CoherenceState::Healthy,
|
||||
CoherenceState::GettingCrowded,
|
||||
CoherenceState::RefreshingContext,
|
||||
CoherenceState::Healthy,
|
||||
CoherenceState::VerifyingRecentWork,
|
||||
CoherenceState::ResettingPlan,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -73,6 +73,86 @@ fn parallel_batch_requires_read_only_parallel_tools() {
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_update_plan_ends_plan_mode_turn_immediately() {
|
||||
assert!(should_stop_after_plan_tool(
|
||||
AppMode::Plan,
|
||||
"update_plan",
|
||||
&Ok(ToolResult::success("planned"))
|
||||
));
|
||||
assert!(!should_stop_after_plan_tool(
|
||||
AppMode::Agent,
|
||||
"update_plan",
|
||||
&Ok(ToolResult::success("planned"))
|
||||
));
|
||||
assert!(!should_stop_after_plan_tool(
|
||||
AppMode::Plan,
|
||||
"request_user_input",
|
||||
&Ok(ToolResult::success("input"))
|
||||
));
|
||||
assert!(!should_stop_after_plan_tool(
|
||||
AppMode::Plan,
|
||||
"update_plan",
|
||||
&Err(ToolError::execution_failed("failed".to_string()))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_plan_requests_force_update_plan_on_first_step() {
|
||||
assert!(should_force_update_plan_first(
|
||||
AppMode::Plan,
|
||||
"Give me a quick 3-step plan to verify the UI changes."
|
||||
));
|
||||
assert!(should_force_update_plan_first(
|
||||
AppMode::Plan,
|
||||
"Make a high-level plan for the footer work."
|
||||
));
|
||||
assert!(!should_force_update_plan_first(
|
||||
AppMode::Plan,
|
||||
"Inspect the repo and then give me a quick plan."
|
||||
));
|
||||
assert!(!should_force_update_plan_first(
|
||||
AppMode::Agent,
|
||||
"Give me a quick 3-step plan."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_plan_turn_can_narrow_first_step_tools_to_update_plan() {
|
||||
let catalog = vec![
|
||||
Tool {
|
||||
tool_type: Some("function".to_string()),
|
||||
name: "read_file".to_string(),
|
||||
description: "Read a file".to_string(),
|
||||
input_schema: json!({"type": "object"}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
},
|
||||
Tool {
|
||||
tool_type: Some("function".to_string()),
|
||||
name: "update_plan".to_string(),
|
||||
description: "Publish a plan".to_string(),
|
||||
input_schema: json!({"type": "object"}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
},
|
||||
];
|
||||
let active = initial_active_tools(&catalog);
|
||||
|
||||
let forced = active_tools_for_step(&catalog, &active, true);
|
||||
assert_eq!(forced.len(), 1);
|
||||
assert_eq!(forced[0].name, "update_plan");
|
||||
|
||||
let default = active_tools_for_step(&catalog, &active, false);
|
||||
assert_eq!(default.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_error_messages_include_actionable_hints() {
|
||||
let path_error = ToolError::path_escape(PathBuf::from("../escape.txt"));
|
||||
@@ -117,6 +197,50 @@ fn non_yolo_mode_retains_default_defer_policy() {
|
||||
assert!(!should_default_defer_tool("read_file", AppMode::Agent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_mode_can_build_auto_approved_tool_context() {
|
||||
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
|
||||
assert!(
|
||||
!engine
|
||||
.build_tool_context(AppMode::Agent, false)
|
||||
.auto_approve
|
||||
);
|
||||
assert!(engine.build_tool_context(AppMode::Agent, true).auto_approve);
|
||||
assert!(engine.build_tool_context(AppMode::Yolo, false).auto_approve);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_update_preserves_reasoning_tool_only_turn() {
|
||||
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
let assistant = Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "Need a tool before answering.".to_string(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: json!({"path": "Cargo.toml"}),
|
||||
caller: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
engine.add_session_message(assistant.clone()).await;
|
||||
|
||||
let event = {
|
||||
let mut rx = handle.rx_event.write().await;
|
||||
rx.recv().await.expect("session update event")
|
||||
};
|
||||
let Event::SessionUpdated { messages, .. } = event else {
|
||||
panic!("expected session update event");
|
||||
};
|
||||
|
||||
assert_eq!(messages, vec![assistant]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_context_length_errors_from_provider_payloads() {
|
||||
let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
|
||||
@@ -128,12 +252,137 @@ fn detects_context_length_errors_from_provider_payloads() {
|
||||
|
||||
#[test]
|
||||
fn context_budget_reserves_output_and_headroom() {
|
||||
let budget = context_input_budget("deepseek-reasoner", TURN_MAX_OUTPUT_TOKENS)
|
||||
let budget = context_input_budget("deepseek-v3.2-128k", TURN_MAX_OUTPUT_TOKENS)
|
||||
.expect("deepseek models should have known context window");
|
||||
let expected = 128_000usize - 4_096usize - 1_024usize;
|
||||
assert_eq!(budget, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_tool_outputs_keep_large_file_reads_in_context() {
|
||||
let content = "0123456789abcdef\n".repeat(2_000);
|
||||
let output = ToolResult::success(content.clone());
|
||||
|
||||
let v4_context = compact_tool_result_for_context("deepseek-v4-pro", "exec_shell", &output);
|
||||
assert_eq!(v4_context, content.trim());
|
||||
|
||||
let legacy_context =
|
||||
compact_tool_result_for_context("deepseek-v3.2-128k", "exec_shell", &output);
|
||||
assert!(legacy_context.contains("output compacted to protect context"));
|
||||
assert!(legacy_context.len() < v4_context.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_system_prompt_places_working_set_after_stable_prefix() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
|
||||
fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write");
|
||||
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(config, &Config::default());
|
||||
engine
|
||||
.session
|
||||
.working_set
|
||||
.observe_user_message("please inspect src/lib.rs", tmp.path());
|
||||
|
||||
engine.refresh_system_prompt(AppMode::Agent);
|
||||
|
||||
let Some(SystemPrompt::Blocks(blocks)) = &engine.session.system_prompt else {
|
||||
panic!("expected structured prompt blocks");
|
||||
};
|
||||
let last = blocks.last().expect("working-set block");
|
||||
assert!(last.text.contains(WORKING_SET_SUMMARY_MARKER));
|
||||
assert!(
|
||||
blocks[..blocks.len() - 1]
|
||||
.iter()
|
||||
.all(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_summary_stays_before_volatile_working_set() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
|
||||
fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
|
||||
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(config, &Config::default());
|
||||
engine
|
||||
.session
|
||||
.working_set
|
||||
.observe_user_message("continue in src/main.rs", tmp.path());
|
||||
engine.refresh_system_prompt(AppMode::Agent);
|
||||
engine.merge_compaction_summary(Some(SystemPrompt::Blocks(vec![SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: format!("{COMPACTION_SUMMARY_MARKER}\nsummary"),
|
||||
cache_control: None,
|
||||
}])));
|
||||
|
||||
let Some(SystemPrompt::Blocks(blocks)) = &engine.session.system_prompt else {
|
||||
panic!("expected structured prompt blocks");
|
||||
};
|
||||
let summary_index = blocks
|
||||
.iter()
|
||||
.position(|block| block.text.contains(COMPACTION_SUMMARY_MARKER))
|
||||
.expect("summary block");
|
||||
let working_set_index = blocks
|
||||
.iter()
|
||||
.position(|block| block.text.contains(WORKING_SET_SUMMARY_MARKER))
|
||||
.expect("working-set block");
|
||||
|
||||
assert!(summary_index < working_set_index);
|
||||
assert_eq!(working_set_index, blocks.len() - 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_request_refresh_skips_compaction_below_normal_threshold() {
|
||||
let capacity = CapacityControllerConfig {
|
||||
enabled: true,
|
||||
low_risk_max: 0.0,
|
||||
medium_risk_max: 1.0,
|
||||
min_turns_before_guardrail: 0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 5;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
engine.session.model = "deepseek-v4-pro".to_string();
|
||||
engine.config.model = "deepseek-v4-pro".to_string();
|
||||
|
||||
for i in 0..20 {
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!("small message {i}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let before = engine.estimated_input_tokens();
|
||||
let before_len = engine.session.messages.len();
|
||||
let turn = TurnContext::new(10);
|
||||
let applied = engine
|
||||
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
|
||||
.await;
|
||||
let after = engine.estimated_input_tokens();
|
||||
|
||||
assert!(!applied);
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(engine.session.messages.len(), before_len);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_request_refresh_invoked_when_medium_risk() {
|
||||
let capacity = CapacityControllerConfig {
|
||||
@@ -152,6 +401,11 @@ async fn pre_request_refresh_invoked_when_medium_risk() {
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
// Pin the model to an explicit 128k-context variant so the pressure ratio stays
|
||||
// stable regardless of changes to the workspace-wide default model.
|
||||
engine.session.model = "deepseek-v3.2-128k".to_string();
|
||||
engine.config.model = "deepseek-v3.2-128k".to_string();
|
||||
|
||||
let long = "x".repeat(5_000);
|
||||
for _ in 0..200 {
|
||||
engine.session.messages.push(Message {
|
||||
@@ -213,7 +467,7 @@ async fn post_tool_replay_invoked_when_high_non_severe_risk() {
|
||||
|
||||
let registry = ToolRegistryBuilder::new()
|
||||
.with_read_only_file_tools()
|
||||
.build(engine.build_tool_context(AppMode::Agent));
|
||||
.build(engine.build_tool_context(AppMode::Agent, false));
|
||||
|
||||
let restarted = engine
|
||||
.run_capacity_post_tool_checkpoint(
|
||||
@@ -492,3 +746,140 @@ fn missing_tool_error_message_includes_discovery_guidance_when_no_match() {
|
||||
assert!(message.contains("not available in the current tool catalog"));
|
||||
assert!(message.contains(TOOL_SEARCH_BM25_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_bracket_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"intro [TOOL_CALL]\n{\"tool\":\"x\"}\n[/TOOL_CALL] outro",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
assert!(!visible.contains("[TOOL_CALL]"));
|
||||
assert!(!visible.contains("[/TOOL_CALL]"));
|
||||
assert!(!visible.contains("\"tool\":\"x\""));
|
||||
assert!(visible.contains("intro"));
|
||||
assert!(visible.contains("outro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_deepseek_xml_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"before <deepseek:tool_call name=\"x\">payload</deepseek:tool_call> after",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
for marker in TOOL_CALL_START_MARKERS {
|
||||
assert!(
|
||||
!visible.contains(marker),
|
||||
"visible text leaked start marker `{marker}`: {visible:?}"
|
||||
);
|
||||
}
|
||||
assert!(visible.contains("before"));
|
||||
assert!(visible.contains("after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_generic_tool_call_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"lead <tool_call>\n{\"name\":\"do\"}\n</tool_call> tail",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
assert!(!visible.contains("<tool_call"));
|
||||
assert!(!visible.contains("</tool_call>"));
|
||||
assert!(visible.contains("lead"));
|
||||
assert!(visible.contains("tail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_invoke_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"alpha <invoke name=\"x\"><parameter name=\"k\">v</parameter></invoke> beta",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
assert!(!visible.contains("<invoke "));
|
||||
assert!(!visible.contains("</invoke>"));
|
||||
assert!(visible.contains("alpha"));
|
||||
assert!(visible.contains("beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_strips_function_calls_marker() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta(
|
||||
"head <function_calls>\n{\"name\":\"x\"}\n</function_calls> tail",
|
||||
&mut in_block,
|
||||
);
|
||||
assert!(!in_block);
|
||||
assert!(!visible.contains("<function_calls>"));
|
||||
assert!(!visible.contains("</function_calls>"));
|
||||
assert!(visible.contains("head"));
|
||||
assert!(visible.contains("tail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_handles_chunk_split_marker() {
|
||||
let mut in_block = false;
|
||||
// First chunk opens the wrapper but does not close it.
|
||||
let visible_a = filter_tool_call_delta("hello <tool_call>partial", &mut in_block);
|
||||
assert!(in_block, "filter must remember it is mid-wrapper");
|
||||
assert_eq!(visible_a, "hello ");
|
||||
|
||||
// Second chunk continues inside the wrapper, then closes it and adds tail.
|
||||
let visible_b = filter_tool_call_delta("payload</tool_call> tail", &mut in_block);
|
||||
assert!(!in_block);
|
||||
assert_eq!(visible_b, " tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_unmatched_open_suppresses_remainder() {
|
||||
let mut in_block = false;
|
||||
let visible = filter_tool_call_delta("ok [TOOL_CALL]rest of stream", &mut in_block);
|
||||
assert_eq!(visible, "ok ");
|
||||
assert!(
|
||||
in_block,
|
||||
"unmatched open must leave filter in tool-call mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_call_delta_passes_through_clean_text() {
|
||||
let mut in_block = false;
|
||||
let input = "no markers here, just prose with code `<not a tag>`.";
|
||||
let visible = filter_tool_call_delta(input, &mut in_block);
|
||||
assert!(!in_block);
|
||||
assert_eq!(visible, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_fake_tool_wrapper_detects_each_marker() {
|
||||
for marker in TOOL_CALL_START_MARKERS {
|
||||
let needle = format!("noise {marker} more noise");
|
||||
assert!(
|
||||
contains_fake_tool_wrapper(&needle),
|
||||
"marker `{marker}` should be detected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_fake_tool_wrapper_returns_false_on_clean_text() {
|
||||
assert!(!contains_fake_tool_wrapper(
|
||||
"plain assistant text without wrappers"
|
||||
));
|
||||
assert!(!contains_fake_tool_wrapper(
|
||||
"`<tool` lookalike but not a real start marker"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_wrapper_notice_is_compact_and_actionable() {
|
||||
// Keep this short so it fits cleanly in a single status line.
|
||||
assert!(FAKE_WRAPPER_NOTICE.len() < 120);
|
||||
assert!(FAKE_WRAPPER_NOTICE.contains("API tool channel"));
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
//! These events flow from the engine to the TUI via a channel,
|
||||
//! enabling non-blocking, real-time updates.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::Usage;
|
||||
use crate::core::coherence::CoherenceState;
|
||||
use crate::models::{Message, SystemPrompt, Usage};
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tools::subagent::SubAgentResult;
|
||||
use crate::tools::user_input::UserInputRequest;
|
||||
@@ -102,6 +105,12 @@ pub enum Event {
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
/// Number of messages before compaction.
|
||||
#[allow(dead_code)]
|
||||
messages_before: Option<usize>,
|
||||
/// Number of messages after compaction.
|
||||
#[allow(dead_code)]
|
||||
messages_after: Option<usize>,
|
||||
},
|
||||
|
||||
/// Context compaction failed.
|
||||
@@ -150,6 +159,14 @@ pub enum Event {
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Plain-language session coherence state.
|
||||
CoherenceState {
|
||||
state: CoherenceState,
|
||||
label: String,
|
||||
description: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
// === Sub-Agent Events ===
|
||||
/// A sub-agent has been spawned
|
||||
AgentSpawned { id: String, prompt: String },
|
||||
@@ -193,6 +210,20 @@ pub enum Event {
|
||||
request: UserInputRequest,
|
||||
},
|
||||
|
||||
/// Authoritative API conversation state from the engine session.
|
||||
///
|
||||
/// The UI receives granular display events, but those are not always a
|
||||
/// lossless representation of the API transcript. DeepSeek can emit
|
||||
/// reasoning directly followed by tool calls without a visible assistant
|
||||
/// text block, and that assistant message still has to be persisted for
|
||||
/// later `reasoning_content` replay.
|
||||
SessionUpdated {
|
||||
messages: Vec<Message>,
|
||||
system_prompt: Option<SystemPrompt>,
|
||||
model: String,
|
||||
workspace: PathBuf,
|
||||
},
|
||||
|
||||
/// Request user decision after sandbox denial
|
||||
#[allow(dead_code)]
|
||||
ElevationRequired {
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
pub mod capacity;
|
||||
pub mod capacity_memory;
|
||||
pub mod coherence;
|
||||
pub mod engine;
|
||||
pub mod events;
|
||||
pub mod ops;
|
||||
@@ -16,8 +16,12 @@ pub enum Op {
|
||||
content: String,
|
||||
mode: AppMode,
|
||||
model: String,
|
||||
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
|
||||
/// `None` lets the provider apply its default.
|
||||
reasoning_effort: Option<String>,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
},
|
||||
|
||||
/// Cancel the current request
|
||||
@@ -71,15 +75,19 @@ impl Op {
|
||||
content: impl Into<String>,
|
||||
mode: AppMode,
|
||||
model: impl Into<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
) -> Self {
|
||||
Op::SendMessage {
|
||||
content: content.into(),
|
||||
mode,
|
||||
model: model.into(),
|
||||
reasoning_effort,
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ pub struct Session {
|
||||
/// Model being used
|
||||
pub model: String,
|
||||
|
||||
/// Reasoning-effort tier for DeepSeek thinking mode:
|
||||
/// `"off" | "low" | "medium" | "high" | "max"`. `None` lets the provider
|
||||
/// apply its own defaults.
|
||||
pub reasoning_effort: Option<String>,
|
||||
|
||||
/// Workspace directory
|
||||
pub workspace: PathBuf,
|
||||
|
||||
@@ -33,6 +38,9 @@ pub struct Session {
|
||||
/// Whether to trust paths outside workspace
|
||||
pub trust_mode: bool,
|
||||
|
||||
/// Whether the current session should auto-approve tool safety checks.
|
||||
pub auto_approve: bool,
|
||||
|
||||
/// Notes file path
|
||||
pub notes_path: PathBuf,
|
||||
|
||||
@@ -66,6 +74,12 @@ impl SessionUsage {
|
||||
pub fn add(&mut self, usage: &Usage) {
|
||||
self.input_tokens += u64::from(usage.input_tokens);
|
||||
self.output_tokens += u64::from(usage.output_tokens);
|
||||
if let Some(tokens) = usage.prompt_cache_miss_tokens {
|
||||
self.cache_creation_input_tokens += u64::from(tokens);
|
||||
}
|
||||
if let Some(tokens) = usage.prompt_cache_hit_tokens {
|
||||
self.cache_read_input_tokens += u64::from(tokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +99,7 @@ impl Session {
|
||||
|
||||
Self {
|
||||
model,
|
||||
reasoning_effort: None,
|
||||
workspace,
|
||||
system_prompt: None,
|
||||
compaction_summary_prompt: None,
|
||||
@@ -92,6 +107,7 @@ impl Session {
|
||||
total_usage: SessionUsage::default(),
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve: false,
|
||||
notes_path,
|
||||
mcp_config_path,
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
File renamed without changes.
@@ -57,7 +57,7 @@ impl TurnContext {
|
||||
usage: Usage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
server_tool_use: None,
|
||||
..Usage::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,25 @@ impl TurnContext {
|
||||
pub fn add_usage(&mut self, usage: &Usage) {
|
||||
self.usage.input_tokens += usage.input_tokens;
|
||||
self.usage.output_tokens += usage.output_tokens;
|
||||
self.usage.prompt_cache_hit_tokens = add_optional_usage(
|
||||
self.usage.prompt_cache_hit_tokens,
|
||||
usage.prompt_cache_hit_tokens,
|
||||
);
|
||||
self.usage.prompt_cache_miss_tokens = add_optional_usage(
|
||||
self.usage.prompt_cache_miss_tokens,
|
||||
usage.prompt_cache_miss_tokens,
|
||||
);
|
||||
self.usage.reasoning_tokens =
|
||||
add_optional_usage(self.usage.reasoning_tokens, usage.reasoning_tokens);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_optional_usage(total: Option<u32>, delta: Option<u32>) -> Option<u32> {
|
||||
match (total, delta) {
|
||||
(Some(total), Some(delta)) => Some(total.saturating_add(delta)),
|
||||
(None, Some(delta)) => Some(delta),
|
||||
(Some(total), None) => Some(total),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Whale/DeepSeek terminal theme tokens.
|
||||
//!
|
||||
//! A small, deliberately flat module that names the color, border, and
|
||||
//! padding choices the TUI is already making. The dark variant matches the
|
||||
//! values previously hard-coded against [`crate::palette`]; the light variant
|
||||
//! is reserved for the future skin swap that issue #14 tracks. Visible output
|
||||
//! is not changed by introducing this module.
|
||||
//!
|
||||
//! The only consumers today are the plan and tool cell renderers in
|
||||
//! [`crate::tui::history`] and the sidebar section chrome in
|
||||
//! [`crate::tui::ui`]. All other call sites continue to use [`crate::palette`]
|
||||
//! directly until they are migrated in a later slice.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{BorderType, Borders, Padding};
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::history::ToolStatus;
|
||||
|
||||
/// Visual variant exposed by the theme.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Variant {
|
||||
Dark,
|
||||
/// Reserved for the future skin swap (issue #14). Not wired up yet.
|
||||
#[allow(dead_code)]
|
||||
Light,
|
||||
}
|
||||
|
||||
/// Centralized visual tokens for sidebar, plan, and tool rendering.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Theme {
|
||||
pub variant: Variant,
|
||||
|
||||
// Sidebar / section chrome
|
||||
pub section_borders: Borders,
|
||||
pub section_border_type: BorderType,
|
||||
pub section_border_color: Color,
|
||||
pub section_bg: Color,
|
||||
pub section_title_color: Color,
|
||||
pub section_padding: Padding,
|
||||
|
||||
// Tool cell color tokens
|
||||
pub tool_title_color: Color,
|
||||
pub tool_value_color: Color,
|
||||
pub tool_label_color: Color,
|
||||
pub tool_running_accent: Color,
|
||||
pub tool_success_accent: Color,
|
||||
pub tool_failed_accent: Color,
|
||||
|
||||
// Plan cell color tokens
|
||||
pub plan_progress_color: Color,
|
||||
pub plan_summary_color: Color,
|
||||
pub plan_explanation_color: Color,
|
||||
pub plan_pending_color: Color,
|
||||
pub plan_in_progress_color: Color,
|
||||
pub plan_completed_color: Color,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The current dark theme. Visible output today uses these values.
|
||||
#[must_use]
|
||||
pub const fn dark() -> Self {
|
||||
Self {
|
||||
variant: Variant::Dark,
|
||||
section_borders: Borders::ALL,
|
||||
section_border_type: BorderType::Plain,
|
||||
section_border_color: palette::BORDER_COLOR,
|
||||
section_bg: palette::DEEPSEEK_INK,
|
||||
section_title_color: palette::DEEPSEEK_BLUE,
|
||||
section_padding: Padding::uniform(1),
|
||||
tool_title_color: palette::TEXT_SOFT,
|
||||
tool_value_color: palette::TEXT_MUTED,
|
||||
tool_label_color: palette::TEXT_DIM,
|
||||
tool_running_accent: palette::ACCENT_TOOL_LIVE,
|
||||
tool_success_accent: palette::TEXT_DIM,
|
||||
tool_failed_accent: palette::ACCENT_TOOL_ISSUE,
|
||||
plan_progress_color: palette::STATUS_SUCCESS,
|
||||
plan_summary_color: palette::TEXT_MUTED,
|
||||
plan_explanation_color: palette::TEXT_DIM,
|
||||
plan_pending_color: palette::TEXT_MUTED,
|
||||
plan_in_progress_color: palette::STATUS_WARNING,
|
||||
plan_completed_color: palette::STATUS_SUCCESS,
|
||||
}
|
||||
}
|
||||
|
||||
/// The light variant. Same RGB values as `dark()` today so a future skin
|
||||
/// swap is a single-source-of-truth change in this file. Out of scope:
|
||||
/// actually picking a distinct light palette.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub const fn light() -> Self {
|
||||
Self {
|
||||
variant: Variant::Light,
|
||||
..Self::dark()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the right tool accent for a given [`ToolStatus`].
|
||||
#[must_use]
|
||||
pub const fn tool_status_color(self, status: ToolStatus) -> Color {
|
||||
match status {
|
||||
ToolStatus::Running => self.tool_running_accent,
|
||||
ToolStatus::Success => self.tool_success_accent,
|
||||
ToolStatus::Failed => self.tool_failed_accent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bold tool title style (e.g. "Plan", "Shell").
|
||||
#[must_use]
|
||||
pub fn tool_title_style(self) -> Style {
|
||||
Style::default()
|
||||
.fg(self.tool_title_color)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Right-side status text ("running", "done", "issue") style.
|
||||
#[must_use]
|
||||
pub fn tool_status_style(self, status: ToolStatus) -> Style {
|
||||
Style::default().fg(self.tool_status_color(status))
|
||||
}
|
||||
|
||||
/// Detail label style ("command:", "time:", step markers).
|
||||
#[must_use]
|
||||
pub fn tool_label_style(self) -> Style {
|
||||
Style::default().fg(self.tool_label_color)
|
||||
}
|
||||
|
||||
/// Default value style for tool detail rows.
|
||||
#[must_use]
|
||||
pub fn tool_value_style(self) -> Style {
|
||||
Style::default().fg(self.tool_value_color)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the active theme used by the TUI today.
|
||||
///
|
||||
/// Today this is always `Theme::dark()`. A future PR can wire this to an
|
||||
/// `App` field or a config setting in five lines.
|
||||
#[must_use]
|
||||
pub const fn active_theme() -> Theme {
|
||||
Theme::dark()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Theme, Variant, active_theme};
|
||||
use crate::palette;
|
||||
use crate::tui::history::ToolStatus;
|
||||
|
||||
#[test]
|
||||
fn active_theme_returns_dark() {
|
||||
assert_eq!(active_theme(), Theme::dark());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_theme_matches_existing_palette_choices() {
|
||||
let theme = Theme::dark();
|
||||
assert_eq!(theme.variant, Variant::Dark);
|
||||
assert_eq!(theme.section_border_color, palette::BORDER_COLOR);
|
||||
assert_eq!(theme.section_bg, palette::DEEPSEEK_INK);
|
||||
assert_eq!(theme.section_title_color, palette::DEEPSEEK_BLUE);
|
||||
assert_eq!(theme.tool_title_color, palette::TEXT_SOFT);
|
||||
assert_eq!(theme.tool_value_color, palette::TEXT_MUTED);
|
||||
assert_eq!(theme.tool_label_color, palette::TEXT_DIM);
|
||||
assert_eq!(theme.tool_running_accent, palette::ACCENT_TOOL_LIVE);
|
||||
assert_eq!(theme.tool_success_accent, palette::TEXT_DIM);
|
||||
assert_eq!(theme.tool_failed_accent, palette::ACCENT_TOOL_ISSUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_theme_keeps_dark_values_until_skin_swap() {
|
||||
// The light variant exists so the future skin swap is a single-file
|
||||
// change. It intentionally mirrors `dark()` today so introducing the
|
||||
// module does not move pixels.
|
||||
let dark = Theme::dark();
|
||||
let light = Theme::light();
|
||||
assert_eq!(light.variant, Variant::Light);
|
||||
assert_eq!(light.section_border_color, dark.section_border_color);
|
||||
assert_eq!(light.tool_running_accent, dark.tool_running_accent);
|
||||
assert_eq!(light.tool_failed_accent, dark.tool_failed_accent);
|
||||
assert_eq!(light.plan_in_progress_color, dark.plan_in_progress_color);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_status_color_maps_each_status() {
|
||||
let theme = Theme::dark();
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Running),
|
||||
theme.tool_running_accent
|
||||
);
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Success),
|
||||
theme.tool_success_accent
|
||||
);
|
||||
assert_eq!(
|
||||
theme.tool_status_color(ToolStatus::Failed),
|
||||
theme.tool_failed_accent
|
||||
);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
// TODO(integrate): Wire feature flags into engine/tool registration — tracked as future work
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Feature flags and metadata for deepseek-cli.
|
||||
//! Feature flags and metadata for DeepSeek TUI.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
@@ -35,7 +35,7 @@ pub enum HookEvent {
|
||||
ToolCallBefore,
|
||||
/// Triggered after a tool completes (success or failure)
|
||||
ToolCallAfter,
|
||||
/// Triggered when the user changes modes (Normal, Edit, Agent, Plan)
|
||||
/// Triggered when the user changes modes (Plan, Agent, Yolo)
|
||||
ModeChange,
|
||||
/// Triggered when an error occurs
|
||||
OnError,
|
||||
File renamed without changes.
@@ -0,0 +1,72 @@
|
||||
//! Lightweight verbose logging helpers for the CLI.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use colored::Colorize;
|
||||
|
||||
use crate::palette;
|
||||
static VERBOSE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Enable or disable verbose logging output.
|
||||
pub fn set_verbose(enabled: bool) {
|
||||
VERBOSE.store(enabled, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Return true when supported env logging knobs request verbose output.
|
||||
#[must_use]
|
||||
pub fn env_requests_verbose_logging() -> bool {
|
||||
std::env::var("DEEPSEEK_LOG_LEVEL")
|
||||
.ok()
|
||||
.is_some_and(|value| log_value_enables_verbose(&value))
|
||||
|| std::env::var("RUST_LOG")
|
||||
.ok()
|
||||
.is_some_and(|value| log_value_enables_verbose(&value))
|
||||
}
|
||||
|
||||
fn log_value_enables_verbose(value: &str) -> bool {
|
||||
value.split(',').any(|directive| {
|
||||
let level = directive
|
||||
.rsplit('=')
|
||||
.next()
|
||||
.unwrap_or(directive)
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
matches!(level.as_str(), "trace" | "debug" | "info")
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether verbose logging is enabled.
|
||||
#[must_use]
|
||||
pub fn is_verbose() -> bool {
|
||||
VERBOSE.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Emit a verbose info message (no-op when verbosity is disabled).
|
||||
pub fn info(message: impl AsRef<str>) {
|
||||
if is_verbose() {
|
||||
let (r, g, b) = palette::DEEPSEEK_SKY_RGB;
|
||||
eprintln!("{} {}", "info".truecolor(r, g, b).bold(), message.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a verbose warning message (no-op when verbosity is disabled).
|
||||
pub fn warn(message: impl AsRef<str>) {
|
||||
if is_verbose() {
|
||||
let (r, g, b) = palette::DEEPSEEK_SKY_RGB;
|
||||
eprintln!("{} {}", "warn".truecolor(r, g, b).bold(), message.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn log_value_parser_accepts_common_rust_log_directives() {
|
||||
assert!(log_value_enables_verbose("debug"));
|
||||
assert!(log_value_enables_verbose("deepseek_cli=debug"));
|
||||
assert!(log_value_enables_verbose("warn,deepseek_tui::client=trace"));
|
||||
assert!(!log_value_enables_verbose("warn"));
|
||||
assert!(!log_value_enables_verbose("deepseek_tui=off"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -498,7 +498,7 @@ impl McpConnection {
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"clientInfo": {
|
||||
"name": "deepseek-cli",
|
||||
"name": "deepseek-tui",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
},
|
||||
"capabilities": {
|
||||
@@ -1478,7 +1478,7 @@ pub fn call_tool(
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"clientInfo": { "name": "deepseek-cli", "version": env!("CARGO_PKG_VERSION") },
|
||||
"clientInfo": { "name": "deepseek-tui", "version": env!("CARGO_PKG_VERSION") },
|
||||
"capabilities": {}
|
||||
}
|
||||
});
|
||||
File renamed without changes.
@@ -3,11 +3,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 128_000;
|
||||
pub const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS: u32 = 1_000_000;
|
||||
pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 50_000;
|
||||
pub const DEFAULT_COMPACTION_MESSAGE_THRESHOLD: usize = 50;
|
||||
const COMPACTION_THRESHOLD_PERCENT: u32 = 80;
|
||||
const COMPACTION_MESSAGE_DIVISOR: u32 = 1200;
|
||||
const MAX_COMPACTION_MESSAGE_THRESHOLD: usize = 150;
|
||||
const COMPACTION_MESSAGE_DIVISOR: u32 = 500;
|
||||
const MAX_COMPACTION_MESSAGE_THRESHOLD: usize = 2_000;
|
||||
|
||||
// === Core Message Types ===
|
||||
|
||||
@@ -27,6 +28,10 @@ pub struct MessageRequest {
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<serde_json::Value>,
|
||||
/// DeepSeek reasoning-effort tier: "off" | "low" | "medium" | "high" | "max".
|
||||
/// Translated by the client into DeepSeek's `reasoning_effort` + `thinking` fields.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -176,11 +181,23 @@ pub struct MessageResponse {
|
||||
}
|
||||
|
||||
/// Token usage metadata for a response.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Usage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_hit_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_miss_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
/// Approximate input tokens spent re-sending prior `reasoning_content`
|
||||
/// across user-message boundaries in DeepSeek V4 thinking-mode tool-calling
|
||||
/// turns (V4 §5.1.1 "Interleaved Thinking"). Estimated client-side at
|
||||
/// ~4 chars/token from the outgoing request body, before the model sees it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_replay_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server_tool_use: Option<ServerToolUsage>,
|
||||
}
|
||||
|
||||
@@ -188,11 +205,15 @@ pub struct Usage {
|
||||
#[must_use]
|
||||
pub fn context_window_for_model(model: &str) -> Option<u32> {
|
||||
let lower = model.to_lowercase();
|
||||
// DeepSeek models default to 128k unless an explicit *k suffix is present.
|
||||
// Unknown DeepSeek model IDs default to 128k unless an explicit *k suffix is present.
|
||||
// DeepSeek-V4 family and current legacy aliases ship with a 1M context window.
|
||||
if lower.contains("deepseek") {
|
||||
if let Some(explicit_window) = deepseek_context_window_hint(&lower) {
|
||||
return Some(explicit_window);
|
||||
}
|
||||
if lower.contains("v4") || is_current_deepseek_v4_alias(&lower) {
|
||||
return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
if lower.contains("claude") {
|
||||
@@ -201,6 +222,13 @@ pub fn context_window_for_model(model: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_current_deepseek_v4_alias(model_lower: &str) -> bool {
|
||||
matches!(
|
||||
model_lower,
|
||||
"deepseek-chat" | "deepseek-reasoner" | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2"
|
||||
)
|
||||
}
|
||||
|
||||
fn deepseek_context_window_hint(model_lower: &str) -> Option<u32> {
|
||||
let bytes = model_lower.as_bytes();
|
||||
let mut i = 0usize;
|
||||
@@ -246,6 +274,49 @@ pub fn compaction_threshold_for_model(model: &str) -> usize {
|
||||
usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD)
|
||||
}
|
||||
|
||||
/// Mode-aware soft context caps for V4 models.
|
||||
///
|
||||
/// DeepSeek V4 paper Figure 9 shows retrieval quality (MRCR MMR) collapses as
|
||||
/// context grows: 0.90 at 8K, 0.94 at 32K, 0.92 at 128K, 0.66 at 512K, 0.49
|
||||
/// at 1M. The paper's own eval harness uses budget tiers per §5.3.1:
|
||||
///
|
||||
/// | Mode / Reasoning tier | Soft cap | Paper eval window |
|
||||
/// |-----------------------|----------|-------------------|
|
||||
/// | Plan / Non-Think (off) | 64,000 | 8K-128K |
|
||||
/// | Agent / High | 192,000 | 128K |
|
||||
/// | YOLO / Max | 384,000 | 384K-512K |
|
||||
///
|
||||
/// These caps keep the agent inside the regime DeepSeek tuned for, triggering
|
||||
/// compaction *before* recall quality degrades. The 1M hard ceiling remains —
|
||||
/// users can override via config or by declining the /compact suggestion.
|
||||
pub const V4_PLAN_SOFT_CAP: usize = 64_000;
|
||||
pub const V4_AGENT_SOFT_CAP: usize = 192_000;
|
||||
pub const V4_YOLO_SOFT_CAP: usize = 384_000;
|
||||
|
||||
/// Compaction threshold keyed by model and caller-supplied effort tier.
|
||||
///
|
||||
/// For V4-family models the threshold is a mode-aware soft cap (see constants
|
||||
/// above). For all other models the legacy 80%-of-window rule applies.
|
||||
#[must_use]
|
||||
pub fn compaction_threshold_for_model_and_effort(
|
||||
model: &str,
|
||||
reasoning_effort: Option<&str>,
|
||||
) -> usize {
|
||||
let lower = model.to_lowercase();
|
||||
if !lower.contains("deepseek")
|
||||
|| !(lower.contains("v4") || is_current_deepseek_v4_alias(&lower))
|
||||
{
|
||||
return compaction_threshold_for_model(model);
|
||||
}
|
||||
|
||||
match reasoning_effort.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some("off" | "disabled" | "none" | "false") => V4_PLAN_SOFT_CAP,
|
||||
Some("low" | "medium" | "high") => V4_AGENT_SOFT_CAP,
|
||||
Some("max" | "maximum" | "xhigh") => V4_YOLO_SOFT_CAP,
|
||||
_ => V4_AGENT_SOFT_CAP,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a compaction message-count threshold from model context window.
|
||||
#[must_use]
|
||||
pub fn compaction_message_threshold_for_model(model: &str) -> usize {
|
||||
@@ -342,13 +413,29 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deepseek_models_map_to_128k_context_window() {
|
||||
fn current_deepseek_aliases_map_to_v4_1m_context_window() {
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-reasoner"),
|
||||
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-chat"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3.2"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_deepseek_models_map_to_128k_context_window() {
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-coder"),
|
||||
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -357,6 +444,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_models_map_to_1m_context_window() {
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v4-pro"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v4-flash"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-ai/deepseek-v4-pro"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_models_with_k_suffix_use_hint() {
|
||||
assert_eq!(context_window_for_model("deepseek-v3.2-32k"), Some(32_000));
|
||||
@@ -372,17 +475,70 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compaction_threshold_scales_with_context_window() {
|
||||
assert_eq!(compaction_threshold_for_model("deepseek-reasoner"), 102_400);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model("deepseek-v3.2-128k"),
|
||||
102_400
|
||||
);
|
||||
assert_eq!(compaction_threshold_for_model("unknown-model"), 50_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_message_threshold_scales_with_context_window() {
|
||||
assert_eq!(
|
||||
compaction_message_threshold_for_model("deepseek-reasoner"),
|
||||
106
|
||||
compaction_message_threshold_for_model("deepseek-v3.2-128k"),
|
||||
256
|
||||
);
|
||||
assert_eq!(compaction_message_threshold_for_model("unknown-model"), 50);
|
||||
assert_eq!(compaction_message_threshold_for_model("claude-3"), 150);
|
||||
// 200k / 500 = 400, within the 2k cap.
|
||||
assert_eq!(compaction_message_threshold_for_model("claude-3"), 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_scales_for_deepseek_v4_1m_context() {
|
||||
assert_eq!(compaction_threshold_for_model("deepseek-v4-pro"), 800_000);
|
||||
assert_eq!(
|
||||
compaction_message_threshold_for_model("deepseek-v4-pro"),
|
||||
2_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_mode_aware_soft_caps() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("off")),
|
||||
V4_PLAN_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("high")),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("max")),
|
||||
V4_YOLO_SOFT_CAP
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_soft_caps_only_apply_to_v4_models() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v3.2-128k", Some("max")),
|
||||
102_400
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("unknown-model", Some("max")),
|
||||
50_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_soft_cap_defaults_to_agent_when_effort_unknown() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", None),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("unknown")),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user