Merge pull request #256 from Hmbown/feat/v0.8.3
feat/v0.8.3: privacy, skills bug fix, palette + schema test coverage
This commit is contained in:
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.3] - 2026-05-01
|
||||
|
||||
### Fixed
|
||||
- **Skills prompt referenced fabricated paths** — `render_available_skills_context`
|
||||
rendered each skill's file as `<skills_dir>/<frontmatter-name>/SKILL.md`,
|
||||
which did not exist when the directory name differed from the frontmatter
|
||||
`name` (community installs, manually-placed skills). `Skill` now carries the
|
||||
real path captured at discovery and renders that.
|
||||
- **Missing-companion error was hostile to direct GitHub Release downloaders**
|
||||
(#258) — replaced "Build workspace default members to install it" wall of
|
||||
text with a concrete three-path checklist: `npm install -g deepseek-tui`,
|
||||
`cargo install deepseek-tui-cli deepseek-tui --locked`, or downloading both
|
||||
`deepseek-<platform>` AND `deepseek-tui-<platform>` from the same Release
|
||||
page. `DEEPSEEK_TUI_BIN` stays as a power-user fallback.
|
||||
|
||||
### Added
|
||||
- **Privacy: `$HOME` contracts to `~` in viewer-visible paths** — the TUI,
|
||||
`deepseek doctor`, `deepseek setup`, and onboarding now contract the home
|
||||
directory to `~` in every path shown on screen, so screenshots, screencasts,
|
||||
and pasted help output do not leak the OS account name. Persisted state,
|
||||
audit log, session checkpoints, and LLM-bound system prompts intentionally
|
||||
keep absolute paths for full fidelity.
|
||||
- **`crates.io` badge** alongside the CI and npm badges in both English and
|
||||
Simplified Chinese READMEs.
|
||||
- **Engine decomposition** (#227) — `core/engine.rs` is split into focused
|
||||
submodules (`engine/{streaming,turn_loop,dispatch,tool_setup,tool_execution,tool_catalog,context,approval,capacity_flow,lsp_hooks,tests}.rs`).
|
||||
No behavior change; preparation for the future agent-loop work.
|
||||
|
||||
### Tests
|
||||
- RLM bridge: `batch_guard` extracted and tested for the empty-batch and
|
||||
oversize-batch invariants; depth-guard fallback covered (partial #231).
|
||||
- Persistence: schema-version rejection covered for `load_session`,
|
||||
`load_offline_queue_state`, `runtime_threads::load_turn`,
|
||||
`runtime_threads::load_item` (partial #233).
|
||||
- Command palette: `[disabled]` server description tag (closes the
|
||||
remaining #197 acceptance gap).
|
||||
- Protocol-recovery contract tests now scan the engine submodules in
|
||||
addition to `engine.rs` so the decomposition refactor doesn't silently
|
||||
hide the fake-wrapper marker assertions.
|
||||
|
||||
### Issue triage
|
||||
- 10 issues closed with verification commits cited (#247, #235, #197,
|
||||
#250, #234, #243, #238, #236, #239, #195).
|
||||
|
||||
## [0.8.2] - 2026-05-01
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+14
-14
@@ -1011,7 +1011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
@@ -1019,7 +1019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1042,7 +1042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-secrets",
|
||||
@@ -1055,7 +1055,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1074,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -1083,7 +1083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1097,7 +1097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -1107,7 +1107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1115,7 +1115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-secrets"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"keyring",
|
||||
@@ -1128,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1140,7 +1140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1153,7 +1153,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -1214,7 +1214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-cli"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1237,7 +1237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
|
||||
@@ -7,5 +7,5 @@ repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
deepseek-config = { path = "../config", version = "0.8.2" }
|
||||
deepseek-config = { path = "../config", version = "0.8.3" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.8.2" }
|
||||
deepseek-config = { path = "../config", version = "0.8.2" }
|
||||
deepseek-core = { path = "../core", version = "0.8.2" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.2" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.2" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.2" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.2" }
|
||||
deepseek-state = { path = "../state", version = "0.8.2" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.2" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.3" }
|
||||
deepseek-config = { path = "../config", version = "0.8.3" }
|
||||
deepseek-core = { path = "../core", version = "0.8.3" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.3" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.3" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.3" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.3" }
|
||||
deepseek-state = { path = "../state", version = "0.8.3" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.3" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -14,13 +14,13 @@ path = "src/main.rs"
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.8.2" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.8.2" }
|
||||
deepseek-config = { path = "../config", version = "0.8.2" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.2" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.2" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.2" }
|
||||
deepseek-state = { path = "../state", version = "0.8.2" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.3" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.8.3" }
|
||||
deepseek-config = { path = "../config", version = "0.8.3" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.3" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.3" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.3" }
|
||||
deepseek-state = { path = "../state", version = "0.8.3" }
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
+11
-1
@@ -1111,7 +1111,17 @@ fn locate_sibling_tui_binary() -> Result<PathBuf> {
|
||||
// expected name, not "deepseek-tui" on Windows.
|
||||
let expected = current.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
|
||||
bail!(
|
||||
"deepseek-tui binary not found at {}. Build workspace default members to install it, or set DEEPSEEK_TUI_BIN to its absolute path.",
|
||||
"Companion `deepseek-tui` binary not found at {}.\n\
|
||||
\n\
|
||||
The `deepseek` dispatcher delegates interactive sessions to a sibling \
|
||||
`deepseek-tui` binary. To fix this, install one of:\n\
|
||||
• npm: npm install -g deepseek-tui (downloads both binaries)\n\
|
||||
• cargo: cargo install deepseek-tui-cli deepseek-tui --locked\n\
|
||||
• GitHub Releases: download BOTH `deepseek-<platform>` AND \
|
||||
`deepseek-tui-<platform>` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \
|
||||
and place them in the same directory.\n\
|
||||
\n\
|
||||
Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.",
|
||||
expected.display()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.2" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.3" }
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.8.2" }
|
||||
deepseek-config = { path = "../config", version = "0.8.2" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.2" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.2" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.2" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.2" }
|
||||
deepseek-state = { path = "../state", version = "0.8.2" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.2" }
|
||||
deepseek-agent = { path = "../agent", version = "0.8.3" }
|
||||
deepseek-config = { path = "../config", version = "0.8.3" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.8.3" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.8.3" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.8.3" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.3" }
|
||||
deepseek-state = { path = "../state", version = "0.8.3" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.3" }
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.2" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.3" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.2" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.3" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.2" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.3" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.2" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.8.3" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -14,9 +14,9 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
deepseek-tui-cli = { path = "../cli", version = "0.8.2" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.2" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.2" }
|
||||
deepseek-tui-cli = { path = "../cli", version = "0.8.3" }
|
||||
deepseek-secrets = { path = "../secrets", version = "0.8.3" }
|
||||
deepseek-tools = { path = "../tools", version = "0.8.3" }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
|
||||
@@ -303,12 +303,6 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "Show current system prompt",
|
||||
usage: "/system",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "context",
|
||||
aliases: &[],
|
||||
description: "Show context window usage",
|
||||
usage: "/context",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "undo",
|
||||
aliases: &[],
|
||||
@@ -687,6 +681,47 @@ mod tests {
|
||||
assert_eq!(links.aliases, &["dashboard", "api"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_registry_has_unique_names_and_aliases() {
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
for command in COMMANDS {
|
||||
assert!(
|
||||
names.insert(command.name),
|
||||
"duplicate command name /{}",
|
||||
command.name
|
||||
);
|
||||
}
|
||||
|
||||
let mut aliases = std::collections::BTreeSet::new();
|
||||
for command in COMMANDS {
|
||||
for alias in command.aliases {
|
||||
assert!(
|
||||
!names.contains(alias),
|
||||
"alias /{} collides with a command name",
|
||||
alias
|
||||
);
|
||||
assert!(aliases.insert(*alias), "duplicate command alias /{alias}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_command_opens_inspector_and_keeps_ctx_alias() {
|
||||
let context = COMMANDS
|
||||
.iter()
|
||||
.find(|cmd| cmd.name == "context")
|
||||
.expect("context command should exist");
|
||||
assert_eq!(context.aliases, &["ctx"]);
|
||||
assert!(context.description.contains("inspector"));
|
||||
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/ctx", &mut app);
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::OpenContextInspector)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_config_opens_config_view_action() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
+47
-1227
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::models::context_window_for_model;
|
||||
|
||||
impl Engine {
|
||||
pub(super) async fn run_capacity_pre_request_checkpoint(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Context budgeting and prompt-shaping helpers for the engine.
|
||||
//!
|
||||
//! These functions are shared by the streaming turn loop, capacity flow, and
|
||||
//! engine session maintenance code. Keeping them here prevents the top-level
|
||||
//! engine module from accumulating unrelated context-policy details.
|
||||
|
||||
use crate::compaction::estimate_tokens;
|
||||
use crate::error_taxonomy::ErrorCategory;
|
||||
use crate::models::{Message, SystemBlock, SystemPrompt, context_window_for_model};
|
||||
use crate::tools::spec::ToolResult;
|
||||
|
||||
/// Max output tokens requested for normal agent turns. Generous on purpose:
|
||||
/// V4 thinking models can produce tens of thousands of reasoning tokens on
|
||||
/// hard prompts before the visible reply, and DeepSeek V4 ships with a 1M
|
||||
/// context window. v0.7.5 keeps this cap fixed instead of silently lowering
|
||||
/// `max_tokens` near pressure; hard-cycle/preflight checks reserve this budget
|
||||
/// plus safety headroom before sending the next request.
|
||||
pub(super) const TURN_MAX_OUTPUT_TOKENS: u32 = 262_144;
|
||||
/// Keep this many most recent messages when emergency trimming is required.
|
||||
pub(super) const MIN_RECENT_MESSAGES_TO_KEEP: usize = 4;
|
||||
/// Allow a few emergency recovery attempts before failing the turn.
|
||||
pub(super) const MAX_CONTEXT_RECOVERY_ATTEMPTS: u8 = 2;
|
||||
/// Reserve additional headroom to avoid hitting provider hard limits.
|
||||
const CONTEXT_HEADROOM_TOKENS: usize = 1024;
|
||||
/// Hard cap for any tool output inserted into model context.
|
||||
const TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS: usize = 12_000;
|
||||
/// Soft cap for known noisy tools inserted into model context.
|
||||
const TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS: usize = 2_000;
|
||||
/// Snippet length kept when compacting tool output for model context.
|
||||
const TOOL_RESULT_CONTEXT_SNIPPET_CHARS: usize = 900;
|
||||
/// Hard cap for tool output inserted into a large-context model.
|
||||
const LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS: usize = 180_000;
|
||||
/// Soft cap for known noisy tools inserted into a large-context model.
|
||||
const LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS: usize = 60_000;
|
||||
/// Snippet length kept when compacting large-context tool output.
|
||||
const LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS: usize = 40_000;
|
||||
/// Context window size at which tool output limits can be relaxed.
|
||||
const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
|
||||
/// Max chars to keep from metadata-provided output summaries.
|
||||
const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320;
|
||||
|
||||
pub(super) const COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)";
|
||||
pub(super) const WORKING_SET_SUMMARY_MARKER: &str = "## Repo Working Set";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ToolResultContextLimits {
|
||||
hard_limit_chars: usize,
|
||||
noisy_soft_limit_chars: usize,
|
||||
snippet_chars: usize,
|
||||
}
|
||||
|
||||
pub(super) fn summarize_text(text: &str, limit: usize) -> String {
|
||||
if text.chars().count() <= limit {
|
||||
return text.to_string();
|
||||
}
|
||||
let take = limit.saturating_sub(3);
|
||||
let mut out: String = text.chars().take(take).collect();
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
|
||||
fn summarize_text_head_tail(text: &str, limit: usize) -> String {
|
||||
let total = text.chars().count();
|
||||
if total <= limit {
|
||||
return text.to_string();
|
||||
}
|
||||
if limit <= 20 {
|
||||
return summarize_text(text, limit);
|
||||
}
|
||||
|
||||
let marker = "\n\n[... output truncated for context ...]\n\n";
|
||||
let marker_len = marker.chars().count();
|
||||
if limit <= marker_len + 20 {
|
||||
return summarize_text(text, limit);
|
||||
}
|
||||
|
||||
let remaining = limit - marker_len;
|
||||
let head_len = remaining.saturating_mul(2) / 3;
|
||||
let tail_len = remaining.saturating_sub(head_len);
|
||||
let head: String = text.chars().take(head_len).collect();
|
||||
let tail_vec: Vec<char> = text.chars().rev().take(tail_len).collect();
|
||||
let tail: String = tail_vec.into_iter().rev().collect();
|
||||
format!("{head}{marker}{tail}")
|
||||
}
|
||||
|
||||
fn tool_result_is_noisy(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"exec_shell"
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "multi_tool_use.parallel"
|
||||
| "web_search"
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_result_metadata_summary(metadata: Option<&serde_json::Value>) -> Option<String> {
|
||||
let obj = metadata?.as_object()?;
|
||||
for key in ["summary", "stdout_summary", "stderr_summary", "message"] {
|
||||
if let Some(text) = obj.get(key).and_then(serde_json::Value::as_str) {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(summarize_text(trimmed, TOOL_RESULT_METADATA_SUMMARY_CHARS));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn tool_result_context_limits_for_model(model: &str) -> ToolResultContextLimits {
|
||||
let is_large_context =
|
||||
context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS);
|
||||
|
||||
if is_large_context {
|
||||
ToolResultContextLimits {
|
||||
hard_limit_chars: LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS,
|
||||
noisy_soft_limit_chars: LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS,
|
||||
snippet_chars: LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS,
|
||||
}
|
||||
} else {
|
||||
ToolResultContextLimits {
|
||||
hard_limit_chars: TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS,
|
||||
noisy_soft_limit_chars: TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS,
|
||||
snippet_chars: TOOL_RESULT_CONTEXT_SNIPPET_CHARS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn compact_tool_result_for_context(
|
||||
model: &str,
|
||||
tool_name: &str,
|
||||
output: &ToolResult,
|
||||
) -> String {
|
||||
let raw = output.content.trim();
|
||||
if raw.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let limits = tool_result_context_limits_for_model(model);
|
||||
let raw_chars = raw.chars().count();
|
||||
let should_compact = raw_chars > limits.hard_limit_chars
|
||||
|| (tool_result_is_noisy(tool_name) && raw_chars > limits.noisy_soft_limit_chars);
|
||||
if !should_compact {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
let snippet = summarize_text_head_tail(raw, limits.snippet_chars);
|
||||
let omitted = raw_chars.saturating_sub(snippet.chars().count());
|
||||
let summary = tool_result_metadata_summary(output.metadata.as_ref());
|
||||
|
||||
if let Some(summary) = summary {
|
||||
format!(
|
||||
"[{tool_name} output compacted to protect context]\nSummary: {summary}\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[{tool_name} output compacted to protect context]\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_compaction_summary_prompt(
|
||||
prompt: Option<SystemPrompt>,
|
||||
) -> Option<SystemPrompt> {
|
||||
match prompt {
|
||||
Some(SystemPrompt::Blocks(blocks)) => {
|
||||
let summary_blocks: Vec<_> = blocks
|
||||
.into_iter()
|
||||
.filter(|block| block.text.contains(COMPACTION_SUMMARY_MARKER))
|
||||
.collect();
|
||||
if summary_blocks.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(SystemPrompt::Blocks(summary_blocks))
|
||||
}
|
||||
}
|
||||
Some(SystemPrompt::Text(text)) => {
|
||||
if text.contains(COMPACTION_SUMMARY_MARKER) {
|
||||
Some(SystemPrompt::Text(text))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_working_set_summary(prompt: Option<&SystemPrompt>) -> Option<SystemPrompt> {
|
||||
match prompt {
|
||||
Some(SystemPrompt::Blocks(blocks)) => {
|
||||
let filtered: Vec<SystemBlock> = blocks
|
||||
.iter()
|
||||
.filter(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER))
|
||||
.cloned()
|
||||
.collect();
|
||||
if filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(SystemPrompt::Blocks(filtered))
|
||||
}
|
||||
}
|
||||
Some(SystemPrompt::Text(text)) => Some(SystemPrompt::Text(text.clone())),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn append_working_set_summary(
|
||||
prompt: Option<SystemPrompt>,
|
||||
working_set_summary: Option<&str>,
|
||||
) -> Option<SystemPrompt> {
|
||||
let Some(summary) = working_set_summary.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return prompt;
|
||||
};
|
||||
let working_set_block = SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: summary.to_string(),
|
||||
cache_control: None,
|
||||
};
|
||||
|
||||
match prompt {
|
||||
Some(SystemPrompt::Text(text)) => Some(SystemPrompt::Blocks(vec![
|
||||
SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text,
|
||||
cache_control: None,
|
||||
},
|
||||
working_set_block,
|
||||
])),
|
||||
Some(SystemPrompt::Blocks(mut blocks)) => {
|
||||
blocks.retain(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER));
|
||||
blocks.push(working_set_block);
|
||||
Some(SystemPrompt::Blocks(blocks))
|
||||
}
|
||||
None => Some(SystemPrompt::Blocks(vec![working_set_block])),
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_text_tokens_conservative(text: &str) -> usize {
|
||||
text.chars().count().div_ceil(3)
|
||||
}
|
||||
|
||||
fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize {
|
||||
match system {
|
||||
Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text),
|
||||
Some(SystemPrompt::Blocks(blocks)) => blocks
|
||||
.iter()
|
||||
.map(|block| estimate_text_tokens_conservative(&block.text))
|
||||
.sum(),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn estimate_input_tokens_conservative(
|
||||
messages: &[Message],
|
||||
system: Option<&SystemPrompt>,
|
||||
) -> usize {
|
||||
let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2);
|
||||
let system_tokens = estimate_system_tokens_conservative(system);
|
||||
let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
|
||||
message_tokens
|
||||
.saturating_add(system_tokens)
|
||||
.saturating_add(framing_overhead)
|
||||
}
|
||||
|
||||
pub(super) fn context_input_budget(model: &str, requested_output_tokens: u32) -> Option<usize> {
|
||||
let window = usize::try_from(context_window_for_model(model)?).ok()?;
|
||||
let output = usize::try_from(requested_output_tokens).ok()?;
|
||||
window
|
||||
.checked_sub(output)
|
||||
.and_then(|v| v.checked_sub(CONTEXT_HEADROOM_TOKENS))
|
||||
}
|
||||
|
||||
pub(super) fn turn_response_headroom_tokens() -> u64 {
|
||||
u64::from(TURN_MAX_OUTPUT_TOKENS).saturating_add(CONTEXT_HEADROOM_TOKENS as u64)
|
||||
}
|
||||
|
||||
pub(super) fn is_context_length_error_message(message: &str) -> bool {
|
||||
crate::error_taxonomy::classify_error_message(message) == ErrorCategory::InvalidInput
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::ToolCaller;
|
||||
use crate::models::{Tool, ToolCaller};
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
@@ -71,6 +71,60 @@ pub(super) enum ToolExecGuard<'a> {
|
||||
Write(#[allow(dead_code)] tokio::sync::RwLockWriteGuard<'a, ()>),
|
||||
}
|
||||
|
||||
// === Caller policy and errors ========================================
|
||||
|
||||
pub(super) fn caller_type_for_tool_use(caller: Option<&ToolCaller>) -> &str {
|
||||
caller.map_or("direct", |c| c.caller_type.as_str())
|
||||
}
|
||||
|
||||
pub(super) fn caller_allowed_for_tool(
|
||||
caller: Option<&ToolCaller>,
|
||||
tool_def: Option<&Tool>,
|
||||
) -> bool {
|
||||
let requested = caller_type_for_tool_use(caller);
|
||||
if let Some(def) = tool_def
|
||||
&& let Some(allowed) = &def.allowed_callers
|
||||
{
|
||||
if allowed.is_empty() {
|
||||
return requested == "direct";
|
||||
}
|
||||
return allowed.iter().any(|item| item == requested);
|
||||
}
|
||||
requested == "direct"
|
||||
}
|
||||
|
||||
pub(super) fn format_tool_error(err: &ToolError, tool_name: &str) -> String {
|
||||
match err {
|
||||
ToolError::InvalidInput { message } => {
|
||||
format!("Invalid input for tool '{tool_name}': {message}")
|
||||
}
|
||||
ToolError::MissingField { field } => {
|
||||
format!("Tool '{tool_name}' is missing required field '{field}'")
|
||||
}
|
||||
ToolError::PathEscape { path } => format!(
|
||||
"Path escapes workspace: {}. Use a workspace-relative path or enable trust mode.",
|
||||
path.display()
|
||||
),
|
||||
ToolError::ExecutionFailed { message } => message.clone(),
|
||||
ToolError::Timeout { seconds } => format!(
|
||||
"Tool '{tool_name}' timed out after {seconds}s. Try a narrower scope or a longer timeout."
|
||||
),
|
||||
ToolError::NotAvailable { message } => {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("current tool catalog") || lower.contains("did you mean:") {
|
||||
message.clone()
|
||||
} else {
|
||||
format!(
|
||||
"Tool '{tool_name}' is not available: {message}. Check mode, feature flags, or tool name."
|
||||
)
|
||||
}
|
||||
}
|
||||
ToolError::PermissionDenied { message } => format!(
|
||||
"Tool '{tool_name}' was denied: {message}. Adjust approval mode or request permission."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// === Streaming-buffer parsing =========================================
|
||||
|
||||
/// Promote a streaming `ToolUseState` to a finalized JSON input.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Post-edit LSP diagnostics hooks for engine tool execution.
|
||||
//!
|
||||
//! The turn loop only needs to ask "did a successful edit produce diagnostics?"
|
||||
//! This module owns the tool-input path extraction and the synthetic diagnostic
|
||||
//! message injection so the top-level engine module stays focused on session
|
||||
//! orchestration.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// #136: derive the file path(s) edited by a tool call. Returns the empty
|
||||
/// vec for tools that don't modify files. We intentionally only handle the
|
||||
/// three known edit tools — adding more (e.g. specialized refactor tools)
|
||||
/// is a one-line change here.
|
||||
pub(super) fn edited_paths_for_tool(tool_name: &str, input: &serde_json::Value) -> Vec<PathBuf> {
|
||||
match tool_name {
|
||||
"edit_file" | "write_file" => {
|
||||
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
|
||||
vec![PathBuf::from(path)]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
"apply_patch" => {
|
||||
// `apply_patch` accepts either a `path` override or a list of
|
||||
// `files` (each `{path, content}`). We try both shapes.
|
||||
let mut out = Vec::new();
|
||||
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
if let Some(files) = input.get("files").and_then(|v| v.as_array()) {
|
||||
for entry in files {
|
||||
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: parse `---`/`+++` headers from a unified diff payload.
|
||||
if out.is_empty()
|
||||
&& let Some(patch) = input.get("patch").and_then(|v| v.as_str())
|
||||
{
|
||||
out.extend(parse_patch_paths(patch));
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight parser for `+++ b/<path>` lines in a unified diff. Used as a
|
||||
/// fallback when `apply_patch` is invoked with raw `patch` text and no
|
||||
/// `path`/`files` override. We deliberately keep this dumb — the real
|
||||
/// `apply_patch` tool already validates the patch shape; we only need a
|
||||
/// best-effort hint for the LSP hook.
|
||||
pub(super) fn parse_patch_paths(patch: &str) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for line in patch.lines() {
|
||||
if let Some(rest) = line.strip_prefix("+++ ") {
|
||||
let trimmed = rest.trim();
|
||||
// Strip leading `b/` per git diff conventions.
|
||||
let path = trimmed.strip_prefix("b/").unwrap_or(trimmed);
|
||||
// Skip `/dev/null` (deletion).
|
||||
if path == "/dev/null" {
|
||||
continue;
|
||||
}
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
/// #136: post-edit hook. Inspects the tool name + input, derives the
|
||||
/// edited file path, and asks the LSP manager for diagnostics. The
|
||||
/// rendered block is queued in `pending_lsp_blocks` and flushed to the
|
||||
/// session message stream just before the next API request. Failure is
|
||||
/// silent by design — a missing/crashing LSP server must never block
|
||||
/// the agent.
|
||||
pub(super) async fn run_post_edit_lsp_hook(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
tool_input: &serde_json::Value,
|
||||
) {
|
||||
if !self.lsp_manager.config().enabled {
|
||||
return;
|
||||
}
|
||||
let paths = edited_paths_for_tool(tool_name, tool_input);
|
||||
for path in paths {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.clone()
|
||||
} else {
|
||||
self.session.workspace.join(&path)
|
||||
};
|
||||
// Use a short edit-sequence based on the existing turn counter so
|
||||
// log output stays correlated even though we do not currently
|
||||
// batch by sequence.
|
||||
let seq = self.turn_counter;
|
||||
if let Some(block) = self.lsp_manager.diagnostics_for(&absolute, seq).await {
|
||||
self.pending_lsp_blocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain `pending_lsp_blocks` into a single synthetic user message so the
|
||||
/// model sees the diagnostics on its next request. Skips when nothing is
|
||||
/// pending. The message uses the standard `text` content block shape
|
||||
/// (the same shape as the post-tool steer messages) so we don't need to
|
||||
/// invent a new envelope.
|
||||
pub(super) async fn flush_pending_lsp_diagnostics(&mut self) {
|
||||
if self.pending_lsp_blocks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let blocks = std::mem::take(&mut self.pending_lsp_blocks);
|
||||
let rendered = crate::lsp::render_blocks(&blocks);
|
||||
if rendered.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.add_session_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: rendered,
|
||||
cache_control: None,
|
||||
}],
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Streaming response state and guardrails.
|
||||
//!
|
||||
//! This module owns the local state used while decoding one model stream:
|
||||
//! content block kind tracking, streamed tool-use buffers, transparent retry
|
||||
//! policy, and scrubbers for text that looks like a forged tool-call wrapper.
|
||||
|
||||
use crate::models::ToolCaller;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ContentBlockKind {
|
||||
Text,
|
||||
Thinking,
|
||||
ToolUse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ToolUseState {
|
||||
pub(super) id: String,
|
||||
pub(super) name: String,
|
||||
pub(super) input: serde_json::Value,
|
||||
pub(super) caller: Option<ToolCaller>,
|
||||
pub(super) input_buffer: String,
|
||||
}
|
||||
|
||||
/// Maximum time to wait for a single stream chunk before assuming a stall.
|
||||
/// **This is the idle timeout** — it resets on every SSE chunk, so long
|
||||
/// thinking turns that ARE producing reasoning_content stay alive. Only a
|
||||
/// genuine `chunk_timeout` window of silence kills the stream.
|
||||
pub(super) const STREAM_CHUNK_TIMEOUT_SECS: u64 = 90;
|
||||
/// Maximum total bytes of text/thinking content before aborting the stream.
|
||||
pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB
|
||||
/// Sanity backstop for total stream wall-clock duration. **Not** a routine
|
||||
/// kill switch — `STREAM_CHUNK_TIMEOUT_SECS` (idle) is the primary stall
|
||||
/// detector. The wall-clock cap is here only to bound pathological cases
|
||||
/// (e.g. a server that keeps sending heartbeats forever without progress).
|
||||
///
|
||||
/// History: this used to be 300s (5 min) which was too aggressive — V4
|
||||
/// thinking turns on hard prompts legitimately exceed 5 minutes wall-clock
|
||||
/// while still emitting reasoning_content chunks the whole way. Bumped to
|
||||
/// 30 min in v0.6.6 to address `TODO_FIXES.md` #1. Codex defaults to a
|
||||
/// per-chunk idle of 300s with no wall-clock cap; we keep both layers but
|
||||
/// give the wall-clock a generous window so it never fires in practice.
|
||||
pub(super) const STREAM_MAX_DURATION_SECS: u64 = 1800; // 30 minutes (was 300s; #103/#1)
|
||||
/// Hard cap on consecutive recoverable stream errors before we surface a turn
|
||||
/// failure. Bumped 3 → 5 in v0.6.7 along with the HTTP/2 keepalive defaults
|
||||
/// (#103) — keepalive should make spurious decode errors rarer, so we can
|
||||
/// tolerate a longer streak before giving up on the turn.
|
||||
pub(super) const MAX_STREAM_ERRORS_BEFORE_FAIL: u32 = 5;
|
||||
/// Cap on transparent stream-level retries — these only happen when the wire
|
||||
/// dies before any content was streamed, so DeepSeek hasn't billed us and
|
||||
/// the user hasn't seen anything. Two attempts is enough to ride out a
|
||||
/// flaky edge node without amplifying real outages (#103).
|
||||
pub(super) const MAX_TRANSPARENT_STREAM_RETRIES: u32 = 2;
|
||||
|
||||
/// Decide whether a stream error is eligible for a transparent retry.
|
||||
///
|
||||
/// True only when ALL three conditions hold:
|
||||
/// 1. No content has been received on the current attempt — otherwise DeepSeek
|
||||
/// has already billed us for output tokens and the user has seen partial
|
||||
/// deltas; resending would double-bill and desync the UI.
|
||||
/// 2. We still have transparent-retry budget remaining.
|
||||
/// 3. The turn has not been cancelled.
|
||||
///
|
||||
/// Extracted as a pure function so the four #103 retry cases can be exercised
|
||||
/// in unit tests without booting the full engine state machine.
|
||||
pub(super) fn should_transparently_retry_stream(
|
||||
any_content_received: bool,
|
||||
transparent_attempts: u32,
|
||||
cancelled: bool,
|
||||
) -> bool {
|
||||
!any_content_received && transparent_attempts < MAX_TRANSPARENT_STREAM_RETRIES && !cancelled
|
||||
}
|
||||
|
||||
pub(crate) const TOOL_CALL_START_MARKERS: [&str; 5] = [
|
||||
"[TOOL_CALL]",
|
||||
"<deepseek:tool_call",
|
||||
"<tool_call",
|
||||
"<invoke ",
|
||||
"<function_calls>",
|
||||
];
|
||||
|
||||
pub(crate) const TOOL_CALL_END_MARKERS: [&str; 5] = [
|
||||
"[/TOOL_CALL]",
|
||||
"</deepseek:tool_call>",
|
||||
"</tool_call>",
|
||||
"</invoke>",
|
||||
"</function_calls>",
|
||||
];
|
||||
|
||||
/// Compact one-shot notice emitted when a model attempts to forge a tool-call
|
||||
/// wrapper in plain text instead of using the API tool channel. The visible
|
||||
/// content is still scrubbed; this exists so the user can see why their text
|
||||
/// shrank.
|
||||
pub(crate) const FAKE_WRAPPER_NOTICE: &str =
|
||||
"Stripped non-API tool-call wrapper from model output (use the API tool channel)";
|
||||
|
||||
/// True if `text` contains any of the known fake-wrapper start markers. Used by
|
||||
/// the streaming loop to decide whether to emit `FAKE_WRAPPER_NOTICE`.
|
||||
pub(crate) fn contains_fake_tool_wrapper(text: &str) -> bool {
|
||||
TOOL_CALL_START_MARKERS.iter().any(|m| text.contains(m))
|
||||
}
|
||||
|
||||
fn find_first_marker(text: &str, markers: &[&str]) -> Option<(usize, usize)> {
|
||||
markers
|
||||
.iter()
|
||||
.filter_map(|marker| text.find(marker).map(|idx| (idx, marker.len())))
|
||||
.min_by_key(|(idx, _)| *idx)
|
||||
}
|
||||
|
||||
pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> String {
|
||||
if delta.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
let mut rest = delta;
|
||||
|
||||
loop {
|
||||
if *in_tool_call {
|
||||
let Some((idx, len)) = find_first_marker(rest, &TOOL_CALL_END_MARKERS) else {
|
||||
break;
|
||||
};
|
||||
rest = &rest[idx + len..];
|
||||
*in_tool_call = false;
|
||||
} else {
|
||||
let Some((idx, len)) = find_first_marker(rest, &TOOL_CALL_START_MARKERS) else {
|
||||
output.push_str(rest);
|
||||
break;
|
||||
};
|
||||
output.push_str(&rest[..idx]);
|
||||
rest = &rest[idx + len..];
|
||||
*in_tool_call = true;
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
use super::context::WORKING_SET_SUMMARY_MARKER;
|
||||
use crate::models::SystemBlock;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -36,6 +38,20 @@ fn make_plan(
|
||||
}
|
||||
}
|
||||
|
||||
fn api_tool(name: &str) -> Tool {
|
||||
Tool {
|
||||
tool_type: Some("function".to_string()),
|
||||
name: name.to_string(),
|
||||
description: format!("Test tool {name}"),
|
||||
input_schema: json!({"type": "object"}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_handle_cancel_tracks_latest_turn_token() {
|
||||
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
@@ -205,6 +221,62 @@ fn non_yolo_mode_retains_default_defer_policy() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_tool_catalog_applies_native_and_mcp_deferral() {
|
||||
let catalog = build_model_tool_catalog(
|
||||
vec![
|
||||
api_tool("read_file"),
|
||||
api_tool("exec_shell"),
|
||||
api_tool("project_map"),
|
||||
],
|
||||
vec![api_tool("list_mcp_resources"), api_tool("mcp_server_write")],
|
||||
AppMode::Agent,
|
||||
);
|
||||
|
||||
let defer_loading = |name: &str| {
|
||||
catalog
|
||||
.iter()
|
||||
.find(|tool| tool.name == name)
|
||||
.and_then(|tool| tool.defer_loading)
|
||||
};
|
||||
|
||||
assert_eq!(defer_loading("read_file"), Some(false));
|
||||
assert_eq!(defer_loading("exec_shell"), Some(false));
|
||||
assert_eq!(defer_loading("project_map"), Some(true));
|
||||
assert_eq!(defer_loading("list_mcp_resources"), Some(false));
|
||||
assert_eq!(defer_loading("mcp_server_write"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_tool_catalog_keeps_everything_loaded_in_yolo_mode() {
|
||||
let catalog = build_model_tool_catalog(
|
||||
vec![api_tool("project_map")],
|
||||
vec![api_tool("mcp_server_write")],
|
||||
AppMode::Yolo,
|
||||
);
|
||||
|
||||
assert!(catalog.iter().all(|tool| tool.defer_loading == Some(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() {
|
||||
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
let registry = engine
|
||||
.build_turn_tool_registry_builder(
|
||||
AppMode::Plan,
|
||||
engine.config.todos.clone(),
|
||||
engine.config.plan_state.clone(),
|
||||
)
|
||||
.build(engine.build_tool_context(AppMode::Plan, false));
|
||||
|
||||
assert!(registry.contains("read_file"));
|
||||
assert!(registry.contains("list_dir"));
|
||||
assert!(!registry.contains("write_file"));
|
||||
assert!(!registry.contains("edit_file"));
|
||||
assert!(registry.contains("update_plan"));
|
||||
assert!(registry.contains("task_create"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_mode_can_build_auto_approved_tool_context() {
|
||||
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Deferred tool catalog and built-in advanced tool helpers.
|
||||
//!
|
||||
//! The streaming turn loop owns when tools are offered or executed. This module
|
||||
//! owns the catalog-level policy around deferred loading, tool search, missing
|
||||
//! tool suggestions, and the small set of built-in advanced tools that are not
|
||||
//! registered by the normal runtime tool registry.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::Tool;
|
||||
use crate::tools::spec::{ToolError, ToolResult, required_str};
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
pub(super) const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel";
|
||||
pub(super) const REQUEST_USER_INPUT_NAME: &str = "request_user_input";
|
||||
pub(super) const CODE_EXECUTION_TOOL_NAME: &str = "code_execution";
|
||||
const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825";
|
||||
const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex";
|
||||
const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119";
|
||||
pub(super) const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25";
|
||||
const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119";
|
||||
|
||||
pub(super) fn is_tool_search_tool(name: &str) -> bool {
|
||||
matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME)
|
||||
}
|
||||
|
||||
pub(super) fn should_default_defer_tool(name: &str, mode: AppMode) -> bool {
|
||||
if mode == AppMode::Yolo {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shell tools are kept active in Agent so the model can run verification
|
||||
// commands (build/test/git/cargo) without first having to discover the
|
||||
// tool through ToolSearch. Plan mode never registers shell tools.
|
||||
let always_loaded_in_action_modes = matches!(mode, AppMode::Agent)
|
||||
&& matches!(
|
||||
name,
|
||||
"exec_shell"
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "exec_wait"
|
||||
| "exec_interact"
|
||||
);
|
||||
if always_loaded_in_action_modes {
|
||||
return false;
|
||||
}
|
||||
|
||||
!matches!(
|
||||
name,
|
||||
"read_file"
|
||||
| "list_dir"
|
||||
| "grep_files"
|
||||
| "file_search"
|
||||
| "diagnostics"
|
||||
| "rlm"
|
||||
| "recall_archive"
|
||||
| MULTI_TOOL_PARALLEL_NAME
|
||||
| "update_plan"
|
||||
| "checklist_write"
|
||||
| "todo_write"
|
||||
| "task_create"
|
||||
| "task_list"
|
||||
| "task_read"
|
||||
| "task_gate_run"
|
||||
| "task_shell_start"
|
||||
| "task_shell_wait"
|
||||
| "github_issue_context"
|
||||
| "github_pr_context"
|
||||
| REQUEST_USER_INPUT_NAME
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn apply_native_tool_deferral(catalog: &mut [Tool], mode: AppMode) {
|
||||
for tool in catalog {
|
||||
tool.defer_loading = Some(should_default_defer_tool(&tool.name, mode));
|
||||
}
|
||||
}
|
||||
|
||||
fn should_keep_mcp_tool_loaded(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"list_mcp_resources"
|
||||
| "list_mcp_resource_templates"
|
||||
| "mcp_read_resource"
|
||||
| "read_mcp_resource"
|
||||
| "mcp_get_prompt"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn apply_mcp_tool_deferral(catalog: &mut [Tool], mode: AppMode) {
|
||||
for tool in catalog {
|
||||
tool.defer_loading =
|
||||
Some(mode != AppMode::Yolo && !should_keep_mcp_tool_loaded(&tool.name));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_model_tool_catalog(
|
||||
mut native_tools: Vec<Tool>,
|
||||
mut mcp_tools: Vec<Tool>,
|
||||
mode: AppMode,
|
||||
) -> Vec<Tool> {
|
||||
apply_native_tool_deferral(&mut native_tools, mode);
|
||||
apply_mcp_tool_deferral(&mut mcp_tools, mode);
|
||||
native_tools.extend(mcp_tools);
|
||||
native_tools
|
||||
}
|
||||
|
||||
pub(super) fn ensure_advanced_tooling(catalog: &mut Vec<Tool>) {
|
||||
if !catalog.iter().any(|t| t.name == CODE_EXECUTION_TOOL_NAME) {
|
||||
catalog.push(Tool {
|
||||
tool_type: Some(CODE_EXECUTION_TOOL_TYPE.to_string()),
|
||||
name: CODE_EXECUTION_TOOL_NAME.to_string(),
|
||||
description: "Execute Python code in a local sandboxed runtime and return stdout/stderr/return_code as JSON.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "string", "description": "Python source code to execute." }
|
||||
},
|
||||
"required": ["code"]
|
||||
}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
|
||||
if !catalog.iter().any(|t| t.name == TOOL_SEARCH_REGEX_NAME) {
|
||||
catalog.push(Tool {
|
||||
tool_type: Some(TOOL_SEARCH_REGEX_TYPE.to_string()),
|
||||
name: TOOL_SEARCH_REGEX_NAME.to_string(),
|
||||
description: "Search deferred tool definitions using a regex query and return matching tool references.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
|
||||
if !catalog.iter().any(|t| t.name == TOOL_SEARCH_BM25_NAME) {
|
||||
catalog.push(Tool {
|
||||
tool_type: Some(TOOL_SEARCH_BM25_TYPE.to_string()),
|
||||
name: TOOL_SEARCH_BM25_NAME.to_string(),
|
||||
description: "Search deferred tool definitions using natural-language matching and return matching tool references.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Natural language query for tool discovery." }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(false),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn initial_active_tools(catalog: &[Tool]) -> HashSet<String> {
|
||||
let mut active = HashSet::new();
|
||||
for tool in catalog {
|
||||
if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) {
|
||||
active.insert(tool.name.clone());
|
||||
}
|
||||
}
|
||||
if active.is_empty()
|
||||
&& !catalog.is_empty()
|
||||
&& let Some(first) = catalog.first()
|
||||
{
|
||||
active.insert(first.name.clone());
|
||||
}
|
||||
active
|
||||
}
|
||||
|
||||
fn active_tool_list_from_catalog(catalog: &[Tool], active: &HashSet<String>) -> Vec<Tool> {
|
||||
catalog
|
||||
.iter()
|
||||
.filter(|tool| active.contains(&tool.name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn active_tools_for_step(
|
||||
catalog: &[Tool],
|
||||
active: &HashSet<String>,
|
||||
force_update_plan: bool,
|
||||
) -> Vec<Tool> {
|
||||
// DeepSeek reasoning models reject explicit named tool_choice forcing here,
|
||||
// so for obvious quick-plan asks we narrow the first-step tool surface to
|
||||
// update_plan instead.
|
||||
if force_update_plan {
|
||||
let forced: Vec<_> = catalog
|
||||
.iter()
|
||||
.filter(|tool| tool.name == "update_plan")
|
||||
.cloned()
|
||||
.collect();
|
||||
if !forced.is_empty() {
|
||||
return forced;
|
||||
}
|
||||
}
|
||||
|
||||
active_tool_list_from_catalog(catalog, active)
|
||||
}
|
||||
|
||||
fn tool_search_haystack(tool: &Tool) -> String {
|
||||
format!(
|
||||
"{}\n{}\n{}",
|
||||
tool.name.to_lowercase(),
|
||||
tool.description.to_lowercase(),
|
||||
tool.input_schema.to_string().to_lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result<Vec<String>, ToolError> {
|
||||
let regex = regex::Regex::new(query)
|
||||
.map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?;
|
||||
|
||||
let mut matches = Vec::new();
|
||||
for tool in catalog {
|
||||
if is_tool_search_tool(&tool.name) {
|
||||
continue;
|
||||
}
|
||||
let hay = tool_search_haystack(tool);
|
||||
if regex.is_match(&hay) {
|
||||
matches.push(tool.name.clone());
|
||||
}
|
||||
if matches.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec<String> {
|
||||
let terms: Vec<String> = query
|
||||
.split_whitespace()
|
||||
.map(|term| term.trim().to_lowercase())
|
||||
.filter(|term| !term.is_empty())
|
||||
.collect();
|
||||
if terms.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut scored: Vec<(i64, String)> = Vec::new();
|
||||
for tool in catalog {
|
||||
if is_tool_search_tool(&tool.name) {
|
||||
continue;
|
||||
}
|
||||
let hay = tool_search_haystack(tool);
|
||||
let mut score = 0i64;
|
||||
for term in &terms {
|
||||
if hay.contains(term) {
|
||||
score += 1;
|
||||
}
|
||||
if tool.name.to_lowercase().contains(term) {
|
||||
score += 2;
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
scored.push((score, tool.name.clone()));
|
||||
}
|
||||
}
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
|
||||
scored.into_iter().take(5).map(|(_, name)| name).collect()
|
||||
}
|
||||
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
if a == b {
|
||||
return 0;
|
||||
}
|
||||
if a.is_empty() {
|
||||
return b.chars().count();
|
||||
}
|
||||
if b.is_empty() {
|
||||
return a.chars().count();
|
||||
}
|
||||
|
||||
let b_chars: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
|
||||
let mut curr = vec![0usize; b_chars.len() + 1];
|
||||
|
||||
for (i, a_ch) in a.chars().enumerate() {
|
||||
curr[0] = i + 1;
|
||||
for (j, b_ch) in b_chars.iter().enumerate() {
|
||||
let cost = if a_ch == *b_ch { 0 } else { 1 };
|
||||
let delete = prev[j + 1] + 1;
|
||||
let insert = curr[j] + 1;
|
||||
let substitute = prev[j] + cost;
|
||||
curr[j + 1] = delete.min(insert).min(substitute);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut curr);
|
||||
}
|
||||
|
||||
prev[b_chars.len()]
|
||||
}
|
||||
|
||||
fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec<String> {
|
||||
let requested = requested.trim().to_ascii_lowercase();
|
||||
if requested.is_empty() || limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut candidates: Vec<(u8, usize, String)> = Vec::new();
|
||||
for tool in catalog {
|
||||
let candidate = tool.name.to_ascii_lowercase();
|
||||
let prefix_match = candidate.starts_with(&requested) || requested.starts_with(&candidate);
|
||||
let contains_match = candidate.contains(&requested) || requested.contains(&candidate);
|
||||
let distance = edit_distance(&candidate, &requested);
|
||||
let close_typo = distance <= 3;
|
||||
|
||||
if !(prefix_match || contains_match || close_typo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rank = if prefix_match {
|
||||
0
|
||||
} else if contains_match {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
candidates.push((rank, distance, tool.name.clone()));
|
||||
}
|
||||
|
||||
candidates.sort_by(|a, b| {
|
||||
a.0.cmp(&b.0)
|
||||
.then_with(|| a.1.cmp(&b.1))
|
||||
.then_with(|| a.2.cmp(&b.2))
|
||||
});
|
||||
candidates.dedup_by(|a, b| a.2 == b.2);
|
||||
candidates
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, _, name)| name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> String {
|
||||
let suggestions = suggest_tool_names(catalog, tool_name, 3);
|
||||
if suggestions.is_empty() {
|
||||
return format!(
|
||||
"Tool '{tool_name}' is not available in the current tool catalog. \
|
||||
Verify mode/feature flags, or use {TOOL_SEARCH_BM25_NAME} with a short query."
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"Tool '{tool_name}' is not available in the current tool catalog. \
|
||||
Did you mean: {}? You can also use {TOOL_SEARCH_BM25_NAME} to discover tools.",
|
||||
suggestions.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn maybe_activate_requested_deferred_tool(
|
||||
tool_name: &str,
|
||||
catalog: &[Tool],
|
||||
active_tools: &mut HashSet<String>,
|
||||
) -> bool {
|
||||
let Some(def) = catalog.iter().find(|def| def.name == tool_name) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !def.defer_loading.unwrap_or(false) || active_tools.contains(tool_name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
active_tools.insert(tool_name.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn execute_tool_search(
|
||||
tool_name: &str,
|
||||
input: &serde_json::Value,
|
||||
catalog: &[Tool],
|
||||
active_tools: &mut HashSet<String>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let query = required_str(input, "query")?;
|
||||
let discovered = if tool_name == TOOL_SEARCH_REGEX_NAME {
|
||||
discover_tools_with_regex(catalog, query)?
|
||||
} else {
|
||||
discover_tools_with_bm25_like(catalog, query)
|
||||
};
|
||||
|
||||
for name in &discovered {
|
||||
active_tools.insert(name.clone());
|
||||
}
|
||||
|
||||
let references = discovered
|
||||
.iter()
|
||||
.map(|name| json!({"type": "tool_reference", "tool_name": name}))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let payload = json!({
|
||||
"type": "tool_search_tool_search_result",
|
||||
"tool_references": references,
|
||||
});
|
||||
|
||||
Ok(ToolResult {
|
||||
content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
|
||||
success: true,
|
||||
metadata: Some(json!({
|
||||
"tool_references": discovered,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn execute_code_execution_tool(
|
||||
input: &serde_json::Value,
|
||||
workspace: &Path,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let code = required_str(input, "code")?;
|
||||
let mut cmd = tokio::process::Command::new("python3");
|
||||
cmd.arg("-c");
|
||||
cmd.arg(code);
|
||||
cmd.current_dir(workspace);
|
||||
|
||||
let output = tokio::time::timeout(Duration::from_secs(120), cmd.output())
|
||||
.await
|
||||
.map_err(|_| ToolError::Timeout { seconds: 120 })
|
||||
.and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let return_code = output.status.code().unwrap_or(-1);
|
||||
let success = output.status.success();
|
||||
let payload = json!({
|
||||
"type": "code_execution_result",
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"return_code": return_code,
|
||||
"content": [],
|
||||
});
|
||||
|
||||
Ok(ToolResult {
|
||||
content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()),
|
||||
success,
|
||||
metadata: Some(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Low-level tool execution helpers for the engine turn loop.
|
||||
//!
|
||||
//! This module keeps the mechanics of MCP dispatch, execution locking, and
|
||||
//! parallel-tool fanout out of `engine.rs`; the turn loop still owns planning,
|
||||
//! approval, and how tool results are written back into session state.
|
||||
|
||||
use std::{fs::OpenOptions, io::Write};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn emit_tool_audit(event: serde_json::Value) {
|
||||
let Some(path) = std::env::var_os("DEEPSEEK_TOOL_AUDIT_LOG") else {
|
||||
return;
|
||||
};
|
||||
let line = match serde_json::to_string(&event) {
|
||||
Ok(line) => line,
|
||||
Err(_) => return,
|
||||
};
|
||||
let path = PathBuf::from(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(file, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub(super) async fn execute_mcp_tool_with_pool(
|
||||
pool: Arc<AsyncMutex<McpPool>>,
|
||||
name: &str,
|
||||
input: serde_json::Value,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let mut pool = pool.lock().await;
|
||||
let result = pool
|
||||
.call_tool(name, input)
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
|
||||
let content = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string());
|
||||
Ok(ToolResult::success(content))
|
||||
}
|
||||
|
||||
pub(super) async fn execute_parallel_tool(
|
||||
&mut self,
|
||||
input: serde_json::Value,
|
||||
tool_registry: Option<&crate::tools::ToolRegistry>,
|
||||
tool_exec_lock: Arc<RwLock<()>>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let calls = parse_parallel_tool_calls(&input)?;
|
||||
let mcp_pool = if calls.iter().any(|(tool, _)| McpPool::is_mcp_tool(tool)) {
|
||||
Some(self.ensure_mcp_pool().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(registry) = tool_registry else {
|
||||
return Err(ToolError::not_available(
|
||||
"tool registry unavailable for multi_tool_use.parallel",
|
||||
));
|
||||
};
|
||||
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
for (tool_name, tool_input) in calls {
|
||||
if tool_name == MULTI_TOOL_PARALLEL_NAME {
|
||||
return Err(ToolError::invalid_input(
|
||||
"multi_tool_use.parallel cannot call itself",
|
||||
));
|
||||
}
|
||||
if McpPool::is_mcp_tool(&tool_name) {
|
||||
if !mcp_tool_is_parallel_safe(&tool_name) {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' is an MCP tool and cannot run in parallel. \
|
||||
Allowed MCP tools: list_mcp_resources, list_mcp_resource_templates, \
|
||||
mcp_read_resource, read_mcp_resource, mcp_get_prompt."
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
let Some(spec) = registry.get(&tool_name) else {
|
||||
return Err(ToolError::not_available(format!(
|
||||
"tool '{tool_name}' is not registered"
|
||||
)));
|
||||
};
|
||||
if !spec.is_read_only() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' is not read-only and cannot run in parallel"
|
||||
)));
|
||||
}
|
||||
if spec.approval_requirement() != ApprovalRequirement::Auto {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' requires approval and cannot run in parallel"
|
||||
)));
|
||||
}
|
||||
if !spec.supports_parallel() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' does not support parallel execution"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let registry_ref = registry;
|
||||
let lock = tool_exec_lock.clone();
|
||||
let tx_event = self.tx_event.clone();
|
||||
let mcp_pool = mcp_pool.clone();
|
||||
tasks.push(async move {
|
||||
let result = Engine::execute_tool_with_lock(
|
||||
lock,
|
||||
true,
|
||||
false,
|
||||
tx_event,
|
||||
tool_name.clone(),
|
||||
tool_input.clone(),
|
||||
Some(registry_ref),
|
||||
mcp_pool,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
(tool_name, result)
|
||||
});
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some((tool_name, result)) = tasks.next().await {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
let mut error = None;
|
||||
if !output.success {
|
||||
error = Some(output.content.clone());
|
||||
}
|
||||
results.push(ParallelToolResultEntry {
|
||||
tool_name,
|
||||
success: output.success,
|
||||
content: output.content,
|
||||
error,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("{err}");
|
||||
results.push(ParallelToolResultEntry {
|
||||
tool_name,
|
||||
success: false,
|
||||
content: format!("Error: {message}"),
|
||||
error: Some(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolResult::json(&ParallelToolResult { results })
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn execute_tool_with_lock(
|
||||
lock: Arc<RwLock<()>>,
|
||||
supports_parallel: bool,
|
||||
interactive: bool,
|
||||
tx_event: mpsc::Sender<Event>,
|
||||
tool_name: String,
|
||||
tool_input: serde_json::Value,
|
||||
registry: Option<&crate::tools::ToolRegistry>,
|
||||
mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
|
||||
context_override: Option<crate::tools::ToolContext>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let _guard = if supports_parallel {
|
||||
ToolExecGuard::Read(lock.read().await)
|
||||
} else {
|
||||
ToolExecGuard::Write(lock.write().await)
|
||||
};
|
||||
|
||||
if interactive {
|
||||
let _ = tx_event.send(Event::PauseEvents).await;
|
||||
}
|
||||
|
||||
let result = if McpPool::is_mcp_tool(&tool_name) {
|
||||
if let Some(pool) = mcp_pool {
|
||||
Engine::execute_mcp_tool_with_pool(pool, &tool_name, tool_input).await
|
||||
} else {
|
||||
Err(ToolError::not_available(format!(
|
||||
"tool '{tool_name}' is not registered"
|
||||
)))
|
||||
}
|
||||
} else if let Some(registry) = registry {
|
||||
registry
|
||||
.execute_full_with_context(&tool_name, tool_input, context_override.as_ref())
|
||||
.await
|
||||
} else {
|
||||
Err(ToolError::not_available(format!(
|
||||
"tool '{tool_name}' is not registered"
|
||||
)))
|
||||
};
|
||||
|
||||
if interactive {
|
||||
let _ = tx_event.send(Event::ResumeEvents).await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Per-turn tool registry setup.
|
||||
//!
|
||||
//! This keeps mode/feature-specific registry construction out of the send path.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Engine {
|
||||
pub(super) fn build_turn_tool_registry_builder(
|
||||
&self,
|
||||
mode: AppMode,
|
||||
todo_list: SharedTodoList,
|
||||
plan_state: SharedPlanState,
|
||||
) -> ToolRegistryBuilder {
|
||||
let mut builder = if mode == AppMode::Plan {
|
||||
ToolRegistryBuilder::new()
|
||||
.with_read_only_file_tools()
|
||||
.with_search_tools()
|
||||
.with_git_tools()
|
||||
.with_git_history_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_validation_tools()
|
||||
.with_runtime_task_tools()
|
||||
.with_todo_tool(todo_list)
|
||||
.with_plan_tool(plan_state)
|
||||
} else {
|
||||
ToolRegistryBuilder::new()
|
||||
.with_agent_tools(self.session.allow_shell)
|
||||
.with_todo_tool(todo_list)
|
||||
.with_plan_tool(plan_state)
|
||||
};
|
||||
|
||||
builder = builder
|
||||
.with_review_tool(self.deepseek_client.clone(), self.session.model.clone())
|
||||
.with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone())
|
||||
.with_user_input_tool()
|
||||
.with_parallel_tool();
|
||||
|
||||
if self.config.features.enabled(Feature::ApplyPatch) && mode != AppMode::Plan {
|
||||
builder = builder.with_patch_tools();
|
||||
}
|
||||
if self.config.features.enabled(Feature::WebSearch) {
|
||||
builder = builder.with_web_tools();
|
||||
}
|
||||
// Plan mode keeps shell available when the session allows it; command
|
||||
// safety and approval checks still gate risky commands.
|
||||
if self.config.features.enabled(Feature::ShellTool) && self.session.allow_shell {
|
||||
builder = builder.with_shell_tools();
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
}
|
||||
+62
-10
@@ -1,10 +1,9 @@
|
||||
// TODO(integrate): Wire feature flags into engine/tool registration — tracked as future work
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Feature flags and metadata for DeepSeek TUI.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::fmt::{self, Write as _};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -18,6 +17,18 @@ pub enum Stage {
|
||||
Removed,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Experimental => "experimental",
|
||||
Self::Beta => "beta",
|
||||
Self::Stable => "stable",
|
||||
Self::Deprecated => "deprecated",
|
||||
Self::Removed => "removed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique features toggled via configuration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Feature {
|
||||
@@ -37,14 +48,7 @@ pub enum Feature {
|
||||
|
||||
impl fmt::Display for Stage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let label = match self {
|
||||
Self::Experimental => "experimental",
|
||||
Self::Beta => "beta",
|
||||
Self::Stable => "stable",
|
||||
Self::Deprecated => "deprecated",
|
||||
Self::Removed => "removed",
|
||||
};
|
||||
f.write_str(label)
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +140,20 @@ pub fn feature_spec_by_key(key: &str) -> Option<&'static FeatureSpec> {
|
||||
FEATURES.iter().find(|spec| spec.key == key)
|
||||
}
|
||||
|
||||
pub fn render_feature_table(features: &Features) -> String {
|
||||
let mut output = String::from("feature\tstage\tenabled\n");
|
||||
for spec in FEATURES {
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"{}\t{}\t{}",
|
||||
spec.key,
|
||||
spec.stage,
|
||||
features.enabled(spec.id)
|
||||
);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Deserializable features table for TOML.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
pub struct FeaturesToml {
|
||||
@@ -190,3 +208,37 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
default_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_map_toggles_known_features_and_ignores_unknown_keys() {
|
||||
let mut features = Features::with_defaults();
|
||||
let entries = BTreeMap::from([
|
||||
("mcp".to_string(), false),
|
||||
("shell_tool".to_string(), false),
|
||||
("not_real".to_string(), false),
|
||||
]);
|
||||
|
||||
features.apply_map(&entries);
|
||||
|
||||
assert!(!features.enabled(Feature::Mcp));
|
||||
assert!(!features.enabled(Feature::ShellTool));
|
||||
assert_eq!(feature_from_key("not_real"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_feature_table_uses_registry_order_and_effective_state() {
|
||||
let mut features = Features::with_defaults();
|
||||
features.disable(Feature::Mcp);
|
||||
|
||||
let table = render_feature_table(&features);
|
||||
let lines = table.lines().collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(lines.first(), Some(&"feature\tstage\tenabled"));
|
||||
assert!(lines.contains(&"shell_tool\tstable\ttrue"));
|
||||
assert!(lines.contains(&"mcp\texperimental\tfalse"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ pub trait LlmClient: Send + Sync {
|
||||
fn model(&self) -> &str;
|
||||
|
||||
/// Creates a non-streaming message completion
|
||||
async fn create_message(&self, request: MessageRequest) -> Result<MessageResponse>;
|
||||
fn create_message(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> impl Future<Output = Result<MessageResponse>> + Send;
|
||||
|
||||
/// Creates a streaming message completion
|
||||
///
|
||||
|
||||
+35
-46
@@ -63,7 +63,7 @@ mod workspace_trust;
|
||||
|
||||
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
|
||||
use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
|
||||
use crate::features::Feature;
|
||||
use crate::features::{Feature, render_feature_table};
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::mcp::{McpConfig, McpPool, McpServerConfig};
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
|
||||
@@ -979,7 +979,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
"DeepSeek Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
|
||||
);
|
||||
println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
|
||||
println!("Workspace: {}", workspace.display());
|
||||
println!("Workspace: {}", crate::utils::display_path(workspace));
|
||||
|
||||
if run_mcp {
|
||||
let mcp_path = config.mcp_config_path();
|
||||
@@ -1022,10 +1022,13 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
if args.local {
|
||||
println!(
|
||||
" Local skills dir enabled for this workspace: {}",
|
||||
skills_dir.display()
|
||||
crate::utils::display_path(&skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(" Skills dir: {}", skills_dir.display());
|
||||
println!(
|
||||
" Skills dir: {}",
|
||||
crate::utils::display_path(&skills_dir)
|
||||
);
|
||||
}
|
||||
println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
|
||||
}
|
||||
@@ -1035,7 +1038,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
|
||||
report_write_status("Tools README", &dir.join("README.md"), readme_status);
|
||||
report_write_status("Example tool", &dir.join("example.sh"), example_status);
|
||||
println!(" Tools dir: {}", dir.display());
|
||||
println!(" Tools dir: {}", crate::utils::display_path(&dir));
|
||||
println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
|
||||
}
|
||||
|
||||
@@ -1045,7 +1048,10 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
init_plugins_dir(&plugins_dir, args.force)?;
|
||||
report_write_status("Plugins README", &readme_path, readme_status);
|
||||
report_write_status("Example plugin", &example_path, example_status);
|
||||
println!(" Plugins dir: {}", plugins_dir.display());
|
||||
println!(
|
||||
" Plugins dir: {}",
|
||||
crate::utils::display_path(&plugins_dir)
|
||||
);
|
||||
println!(" Next: copy the example dir, edit PLUGIN.md, wire via skill/MCP.");
|
||||
}
|
||||
|
||||
@@ -1200,7 +1206,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
println!(
|
||||
" · skills: {} at {}",
|
||||
skills_count_for(&skills_dir),
|
||||
skills_dir.display()
|
||||
crate::utils::display_path(&skills_dir)
|
||||
);
|
||||
|
||||
let tools_dir = default_tools_dir();
|
||||
@@ -1216,7 +1222,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
tools_dir.display()
|
||||
crate::utils::display_path(&tools_dir)
|
||||
);
|
||||
|
||||
let plugins_dir = default_plugins_dir();
|
||||
@@ -1232,7 +1238,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
plugins_dir.display()
|
||||
crate::utils::display_path(&plugins_dir)
|
||||
);
|
||||
|
||||
let sandbox = crate::sandbox::get_platform_sandbox();
|
||||
@@ -1348,16 +1354,16 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} config.toml found at {}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
config_path.display()
|
||||
crate::utils::display_path(&config_path)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} config.toml not found at {} (using defaults/env)",
|
||||
"!".truecolor(sky_r, sky_g, sky_b),
|
||||
config_path.display()
|
||||
crate::utils::display_path(&config_path)
|
||||
);
|
||||
}
|
||||
println!(" workspace: {}", workspace.display());
|
||||
println!(" workspace: {}", crate::utils::display_path(workspace));
|
||||
|
||||
// Check API keys
|
||||
println!();
|
||||
@@ -1477,7 +1483,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} MCP config found at {}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
mcp_config_path.display()
|
||||
crate::utils::display_path(&mcp_config_path)
|
||||
);
|
||||
match load_mcp_config(&mcp_config_path) {
|
||||
Ok(cfg) if cfg.servers.is_empty() => {
|
||||
@@ -1532,7 +1538,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} MCP config not found at {}",
|
||||
"·".dimmed(),
|
||||
mcp_config_path.display()
|
||||
crate::utils::display_path(&mcp_config_path)
|
||||
);
|
||||
println!(" Run `deepseek mcp init` or `deepseek setup --mcp`.");
|
||||
}
|
||||
@@ -1561,14 +1567,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} local skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
local_skills_dir.display(),
|
||||
crate::utils::display_path(&local_skills_dir),
|
||||
describe_dir(&local_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} local skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
local_skills_dir.display()
|
||||
crate::utils::display_path(&local_skills_dir)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1576,14 +1582,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} .agents skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
agents_skills_dir.display(),
|
||||
crate::utils::display_path(&agents_skills_dir),
|
||||
describe_dir(&agents_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} .agents skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
agents_skills_dir.display()
|
||||
crate::utils::display_path(&agents_skills_dir)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1591,21 +1597,21 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} global skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
global_skills_dir.display(),
|
||||
crate::utils::display_path(&global_skills_dir),
|
||||
describe_dir(&global_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} global skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
global_skills_dir.display()
|
||||
crate::utils::display_path(&global_skills_dir)
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} selected skills dir: {}",
|
||||
"·".dimmed(),
|
||||
selected_skills_dir.display()
|
||||
crate::utils::display_path(selected_skills_dir)
|
||||
);
|
||||
if !agents_skills_dir.exists() && !local_skills_dir.exists() && !global_skills_dir.exists() {
|
||||
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
|
||||
@@ -1620,14 +1626,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} tools dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
tools_dir.display(),
|
||||
crate::utils::display_path(&tools_dir),
|
||||
count
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} tools dir not found at {}",
|
||||
"·".dimmed(),
|
||||
tools_dir.display()
|
||||
crate::utils::display_path(&tools_dir)
|
||||
);
|
||||
println!(" Run `deepseek-tui setup --tools` to scaffold a starter dir.");
|
||||
}
|
||||
@@ -1641,14 +1647,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} plugins dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
plugins_dir.display(),
|
||||
crate::utils::display_path(&plugins_dir),
|
||||
count
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} plugins dir not found at {}",
|
||||
"·".dimmed(),
|
||||
plugins_dir.display()
|
||||
crate::utils::display_path(&plugins_dir)
|
||||
);
|
||||
println!(" Run `deepseek-tui setup --plugins` to scaffold a starter dir.");
|
||||
}
|
||||
@@ -1870,30 +1876,13 @@ fn run_execpolicy_command(command: ExecpolicyCommand) -> Result<()> {
|
||||
|
||||
fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
|
||||
match command.command {
|
||||
FeaturesSubcommand::List => run_features_list(config),
|
||||
FeaturesSubcommand::List => {
|
||||
print!("{}", render_feature_table(&config.features()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_str(stage: features::Stage) -> &'static str {
|
||||
match stage {
|
||||
features::Stage::Experimental => "experimental",
|
||||
features::Stage::Beta => "beta",
|
||||
features::Stage::Stable => "stable",
|
||||
features::Stage::Deprecated => "deprecated",
|
||||
features::Stage::Removed => "removed",
|
||||
}
|
||||
}
|
||||
|
||||
fn run_features_list(config: &Config) -> Result<()> {
|
||||
let features = config.features();
|
||||
println!("feature\tstage\tenabled");
|
||||
for spec in features::FEATURES {
|
||||
let enabled = features.enabled(spec.id);
|
||||
println!("{}\t{}\t{enabled}", spec.key, stage_str(spec.stage));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
|
||||
use crate::client::DeepSeekClient;
|
||||
|
||||
|
||||
+224
-36
@@ -13,13 +13,14 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_util::future::join_all;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::client::DeepSeekClient;
|
||||
use crate::llm_client::LlmClient as _;
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Usage};
|
||||
use crate::repl::runtime::{BatchResp, RpcDispatcher, RpcRequest, RpcResponse, SingleResp};
|
||||
|
||||
/// Per-child completion timeout — same as the previous sidecar default.
|
||||
@@ -29,18 +30,46 @@ const DEFAULT_CHILD_MAX_TOKENS: u32 = 4096;
|
||||
/// Hard cap on prompts per batch RPC.
|
||||
pub const MAX_BATCH: usize = 16;
|
||||
|
||||
/// Object-safe slice of the LLM client interface that the RLM bridge needs.
|
||||
///
|
||||
/// `LlmClient` itself uses native async trait methods, which are not dyn-safe.
|
||||
/// The bridge only needs non-streaming completions, so this boxed-future shim
|
||||
/// gives tests a clean mock seam without changing the wider provider trait.
|
||||
pub(crate) trait RlmLlmClient: Send + Sync {
|
||||
fn create_message_boxed(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Pin<Box<dyn Future<Output = Result<MessageResponse>> + Send + '_>>;
|
||||
}
|
||||
|
||||
impl<T> RlmLlmClient for T
|
||||
where
|
||||
T: LlmClient + Send + Sync,
|
||||
{
|
||||
fn create_message_boxed(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Pin<Box<dyn Future<Output = Result<MessageResponse>> + Send + '_>> {
|
||||
Box::pin(self.create_message(request))
|
||||
}
|
||||
}
|
||||
|
||||
/// State shared with the bridge across all RPC calls in one turn.
|
||||
pub struct RlmBridge {
|
||||
pub client: DeepSeekClient,
|
||||
pub child_model: String,
|
||||
client: Arc<dyn RlmLlmClient>,
|
||||
child_model: String,
|
||||
/// Recursion budget remaining for `Rlm` / `RlmBatch` requests. When
|
||||
/// zero, those requests fall back to plain `Llm` completions.
|
||||
pub depth_remaining: u32,
|
||||
pub usage: Arc<Mutex<Usage>>,
|
||||
depth_remaining: u32,
|
||||
usage: Arc<Mutex<Usage>>,
|
||||
}
|
||||
|
||||
impl RlmBridge {
|
||||
pub fn new(client: DeepSeekClient, child_model: String, depth_remaining: u32) -> Self {
|
||||
pub(crate) fn new(
|
||||
client: Arc<dyn RlmLlmClient>,
|
||||
child_model: String,
|
||||
depth_remaining: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
child_model,
|
||||
@@ -83,7 +112,7 @@ impl RlmBridge {
|
||||
top_p: Some(0.9_f32),
|
||||
};
|
||||
|
||||
let fut = self.client.create_message(request);
|
||||
let fut = self.client.create_message_boxed(request);
|
||||
let response =
|
||||
match tokio::time::timeout(Duration::from_secs(CHILD_TIMEOUT_SECS), fut).await {
|
||||
Ok(Ok(r)) => r,
|
||||
@@ -121,19 +150,8 @@ impl RlmBridge {
|
||||
}
|
||||
|
||||
async fn dispatch_llm_batch(&self, prompts: Vec<String>, model: Option<String>) -> BatchResp {
|
||||
if prompts.is_empty() {
|
||||
return BatchResp { results: vec![] };
|
||||
}
|
||||
if prompts.len() > MAX_BATCH {
|
||||
return BatchResp {
|
||||
results: prompts
|
||||
.iter()
|
||||
.map(|_| SingleResp {
|
||||
text: String::new(),
|
||||
error: Some(format!("batch too large: {} > {MAX_BATCH}", prompts.len())),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
if let Some(resp) = batch_guard(prompts.len()) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
let model = Arc::new(
|
||||
@@ -176,7 +194,7 @@ impl RlmBridge {
|
||||
// Recursive call. The dyn-erasure on `run_rlm_turn_inner` breaks
|
||||
// the `bridge → turn → bridge` opaque-future cycle.
|
||||
let result = super::turn::run_rlm_turn_inner(
|
||||
&self.client,
|
||||
Arc::clone(&self.client),
|
||||
child_model.clone(),
|
||||
prompt,
|
||||
None,
|
||||
@@ -201,19 +219,8 @@ impl RlmBridge {
|
||||
}
|
||||
|
||||
async fn dispatch_rlm_batch(&self, prompts: Vec<String>, model: Option<String>) -> BatchResp {
|
||||
if prompts.is_empty() {
|
||||
return BatchResp { results: vec![] };
|
||||
}
|
||||
if prompts.len() > MAX_BATCH {
|
||||
return BatchResp {
|
||||
results: prompts
|
||||
.iter()
|
||||
.map(|_| SingleResp {
|
||||
text: String::new(),
|
||||
error: Some(format!("batch too large: {} > {MAX_BATCH}", prompts.len())),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
if let Some(resp) = batch_guard(prompts.len()) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
let model = Arc::new(model);
|
||||
@@ -227,6 +234,23 @@ impl RlmBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fn batch_guard(prompt_count: usize) -> Option<BatchResp> {
|
||||
if prompt_count == 0 {
|
||||
return Some(BatchResp { results: vec![] });
|
||||
}
|
||||
if prompt_count > MAX_BATCH {
|
||||
return Some(BatchResp {
|
||||
results: (0..prompt_count)
|
||||
.map(|_| SingleResp {
|
||||
text: String::new(),
|
||||
error: Some(format!("batch too large: {prompt_count} > {MAX_BATCH}")),
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl RpcDispatcher for RlmBridge {
|
||||
fn dispatch<'a>(
|
||||
&'a self,
|
||||
@@ -255,3 +279,167 @@ impl RpcDispatcher for RlmBridge {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::mock::MockLlmClient;
|
||||
|
||||
fn mock_response(text: &str, input_tokens: u32, output_tokens: u32) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: "mock_msg".to_string(),
|
||||
r#type: "message".to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
model: "mock-model".to_string(),
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
stop_sequence: None,
|
||||
container: None,
|
||||
usage: Usage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
..Usage::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_for(mock: Arc<MockLlmClient>, depth_remaining: u32) -> RlmBridge {
|
||||
let client: Arc<dyn RlmLlmClient> = mock;
|
||||
RlmBridge::new(client, "child-model".to_string(), depth_remaining)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_guard_allows_non_empty_batches_at_the_cap() {
|
||||
assert!(batch_guard(MAX_BATCH).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_guard_returns_empty_response_for_empty_batches() {
|
||||
let response = batch_guard(0).expect("empty batch should be handled");
|
||||
assert!(response.results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_guard_returns_one_error_per_oversized_prompt() {
|
||||
let response = batch_guard(MAX_BATCH + 2).expect("oversized batch should be handled");
|
||||
assert_eq!(response.results.len(), MAX_BATCH + 2);
|
||||
assert!(response.results.iter().all(|result| {
|
||||
result.text.is_empty()
|
||||
&& result
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|err| err.contains("batch too large"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_dispatch_uses_trait_backed_mock_client() {
|
||||
let mock = Arc::new(MockLlmClient::new(Vec::new()));
|
||||
mock.push_message_response(mock_response("child answer", 7, 11));
|
||||
let bridge = bridge_for(Arc::clone(&mock), 1);
|
||||
|
||||
let response = bridge
|
||||
.dispatch(RpcRequest::Llm {
|
||||
prompt: "child prompt".to_string(),
|
||||
model: Some("override-model".to_string()),
|
||||
max_tokens: Some(123),
|
||||
system: Some("child system".to_string()),
|
||||
})
|
||||
.await;
|
||||
|
||||
match response {
|
||||
RpcResponse::Single(single) => {
|
||||
assert_eq!(single.text, "child answer");
|
||||
assert!(single.error.is_none());
|
||||
}
|
||||
other => panic!("expected single response, got {other:?}"),
|
||||
}
|
||||
|
||||
let captured = mock.captured_requests();
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(captured[0].model, "override-model");
|
||||
assert_eq!(captured[0].max_tokens, 123);
|
||||
assert_eq!(
|
||||
captured[0].system,
|
||||
Some(SystemPrompt::Text("child system".to_string()))
|
||||
);
|
||||
|
||||
let usage = bridge.usage.lock().await;
|
||||
assert_eq!(usage.input_tokens, 7);
|
||||
assert_eq!(usage.output_tokens, 11);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_batch_dispatch_preserves_result_count_and_usage() {
|
||||
let mock = Arc::new(MockLlmClient::new(Vec::new()));
|
||||
mock.push_message_response(mock_response("one", 1, 2));
|
||||
mock.push_message_response(mock_response("two", 3, 4));
|
||||
mock.push_message_response(mock_response("three", 5, 6));
|
||||
let bridge = bridge_for(Arc::clone(&mock), 1);
|
||||
|
||||
let response = bridge
|
||||
.dispatch(RpcRequest::LlmBatch {
|
||||
prompts: vec!["a".to_string(), "b".to_string(), "c".to_string()],
|
||||
model: Some("batch-model".to_string()),
|
||||
})
|
||||
.await;
|
||||
|
||||
match response {
|
||||
RpcResponse::Batch(batch) => {
|
||||
let texts: Vec<_> = batch
|
||||
.results
|
||||
.iter()
|
||||
.map(|result| result.text.as_str())
|
||||
.collect();
|
||||
assert_eq!(texts, ["one", "two", "three"]);
|
||||
assert!(batch.results.iter().all(|result| result.error.is_none()));
|
||||
}
|
||||
other => panic!("expected batch response, got {other:?}"),
|
||||
}
|
||||
|
||||
let captured = mock.captured_requests();
|
||||
assert_eq!(captured.len(), 3);
|
||||
assert!(
|
||||
captured
|
||||
.iter()
|
||||
.all(|request| request.model == "batch-model")
|
||||
);
|
||||
|
||||
let usage = bridge.usage.lock().await;
|
||||
assert_eq!(usage.input_tokens, 9);
|
||||
assert_eq!(usage.output_tokens, 12);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rlm_dispatch_at_depth_zero_falls_back_to_plain_llm_query() {
|
||||
let mock = Arc::new(MockLlmClient::new(Vec::new()));
|
||||
mock.push_message_response(mock_response("fallback answer", 3, 5));
|
||||
let bridge = bridge_for(Arc::clone(&mock), 0);
|
||||
|
||||
let response = bridge
|
||||
.dispatch(RpcRequest::Rlm {
|
||||
prompt: "nested prompt".to_string(),
|
||||
model: Some("override-model".to_string()),
|
||||
})
|
||||
.await;
|
||||
|
||||
match response {
|
||||
RpcResponse::Single(single) => {
|
||||
assert_eq!(single.text, "fallback answer");
|
||||
assert!(single.error.is_none());
|
||||
}
|
||||
other => panic!("expected single response, got {other:?}"),
|
||||
}
|
||||
|
||||
let usage = bridge.usage.lock().await;
|
||||
assert_eq!(usage.input_tokens, 3);
|
||||
assert_eq!(usage.output_tokens, 5);
|
||||
|
||||
let captured = mock.captured_requests();
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(captured[0].model, "override-model");
|
||||
}
|
||||
}
|
||||
|
||||
+67
-25
@@ -2,6 +2,7 @@
|
||||
//! subprocess + stdin/stdout RPC bridge (no HTTP sidecar).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
@@ -9,11 +10,10 @@ use uuid::Uuid;
|
||||
|
||||
use crate::client::DeepSeekClient;
|
||||
use crate::core::events::Event;
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, Usage};
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
|
||||
use crate::repl::PythonRuntime;
|
||||
|
||||
use super::bridge::RlmBridge;
|
||||
use super::bridge::{RlmBridge, RlmLlmClient};
|
||||
use super::prompt::rlm_system_prompt;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -99,7 +99,7 @@ pub async fn run_rlm_turn(
|
||||
max_depth: u32,
|
||||
) -> RlmTurnResult {
|
||||
run_rlm_turn_inner(
|
||||
client,
|
||||
Arc::new(client.clone()),
|
||||
model,
|
||||
prompt,
|
||||
None,
|
||||
@@ -122,7 +122,7 @@ pub async fn run_rlm_turn_with_root(
|
||||
max_depth: u32,
|
||||
) -> RlmTurnResult {
|
||||
run_rlm_turn_inner(
|
||||
client,
|
||||
Arc::new(client.clone()),
|
||||
model,
|
||||
prompt,
|
||||
root_prompt,
|
||||
@@ -136,15 +136,15 @@ pub async fn run_rlm_turn_with_root(
|
||||
/// Inner entry point — also used by the bridge when it recurses. Returns
|
||||
/// a boxed future to break the recursive opaque-future-type cycle:
|
||||
/// `run_rlm_turn_inner` → `RlmBridge::dispatch` → `run_rlm_turn_inner`.
|
||||
pub(crate) fn run_rlm_turn_inner<'a>(
|
||||
client: &'a DeepSeekClient,
|
||||
pub(crate) fn run_rlm_turn_inner(
|
||||
client: Arc<dyn RlmLlmClient>,
|
||||
model: String,
|
||||
prompt: String,
|
||||
root_prompt: Option<String>,
|
||||
child_model: String,
|
||||
tx_event: mpsc::Sender<Event>,
|
||||
max_depth: u32,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = RlmTurnResult> + Send + 'a>> {
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = RlmTurnResult> + Send>> {
|
||||
Box::pin(run_rlm_turn_impl(
|
||||
client,
|
||||
model,
|
||||
@@ -161,7 +161,7 @@ pub(crate) fn run_rlm_turn_inner<'a>(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_rlm_turn_impl(
|
||||
client: &DeepSeekClient,
|
||||
client: Arc<dyn RlmLlmClient>,
|
||||
model: String,
|
||||
prompt: String,
|
||||
root_prompt: Option<String>,
|
||||
@@ -212,7 +212,7 @@ async fn run_rlm_turn_impl(
|
||||
};
|
||||
|
||||
// 3. Build the bridge that services llm_query / rlm_query RPCs.
|
||||
let bridge = RlmBridge::new(client.clone(), child_model.clone(), max_depth);
|
||||
let bridge = RlmBridge::new(Arc::clone(&client), child_model.clone(), max_depth);
|
||||
let usage_handle = bridge.usage_handle();
|
||||
|
||||
let _ = tx_event
|
||||
@@ -262,22 +262,9 @@ async fn run_rlm_turn_impl(
|
||||
.await;
|
||||
|
||||
// 4a. Root LLM generates code from metadata-only context.
|
||||
let request = MessageRequest {
|
||||
model: model.clone(),
|
||||
messages: messages.clone(),
|
||||
max_tokens: ROOT_MAX_TOKENS,
|
||||
system: Some(system.clone()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: None,
|
||||
stream: Some(false),
|
||||
temperature: Some(ROOT_TEMPERATURE),
|
||||
top_p: Some(0.9_f32),
|
||||
};
|
||||
let request = build_root_request(&model, &messages, &system);
|
||||
|
||||
let response = match client.create_message(request).await {
|
||||
let response = match client.create_message_boxed(request).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
break 'turn RlmTurnResult {
|
||||
@@ -551,6 +538,23 @@ fn write_context_file(prompt: &str) -> std::io::Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn build_root_request(model: &str, messages: &[Message], system: &SystemPrompt) -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: model.to_string(),
|
||||
messages: messages.to_vec(),
|
||||
max_tokens: ROOT_MAX_TOKENS,
|
||||
system: Some(system.clone()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: None,
|
||||
stream: Some(false),
|
||||
temperature: Some(ROOT_TEMPERATURE),
|
||||
top_p: Some(0.9_f32),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `Metadata(state)` from the paper. Surfaces:
|
||||
/// - the small `root_prompt` (if any) — repeated each iteration
|
||||
/// - `context` length + preview
|
||||
@@ -835,6 +839,44 @@ mod tests {
|
||||
assert!(text.contains("FINAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_metadata_truncates_long_context_without_leaking_tail() {
|
||||
let secret_tail = "DO_NOT_LEAK_CONTEXT_TAIL";
|
||||
let prompt = format!("{}{}", "a".repeat(PROMPT_PREVIEW_LEN + 100), secret_tail);
|
||||
let msg = build_metadata_message(&prompt, None, 0, None, None);
|
||||
let text = extract_text_blocks(&msg.content);
|
||||
|
||||
assert!(text.contains(&format!("- Length: {} chars", prompt.chars().count())));
|
||||
assert!(text.contains("- Preview: \""));
|
||||
assert!(text.contains("..."));
|
||||
assert!(
|
||||
!text.contains(secret_tail),
|
||||
"metadata leaked the non-preview tail of context"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_root_request_keeps_context_tail_out_of_root_payload() {
|
||||
let secret_tail = "DO_NOT_LEAK_ROOT_REQUEST";
|
||||
let prompt = format!("{}{}", "a".repeat(PROMPT_PREVIEW_LEN + 100), secret_tail);
|
||||
let messages = vec![build_metadata_message(
|
||||
&prompt,
|
||||
Some("answer from the long context"),
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)];
|
||||
|
||||
let request = build_root_request("root-model", &messages, &rlm_system_prompt());
|
||||
let payload = serde_json::to_string(&request).expect("request should serialize");
|
||||
|
||||
assert!(payload.contains(&format!("- Length: {} chars", prompt.chars().count())));
|
||||
assert!(
|
||||
!payload.contains(secret_tail),
|
||||
"root LLM request leaked the non-preview tail of context"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_metadata_with_iteration_shows_previous_code() {
|
||||
let msg = build_metadata_message("Test prompt", None, 3, Some("print('hi')"), Some("hi"));
|
||||
|
||||
@@ -44,6 +44,11 @@ pub struct Skill {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
/// On-disk path to the `SKILL.md` this was loaded from. The directory
|
||||
/// name can differ from the frontmatter `name` for community installs
|
||||
/// or manually-placed skills, so callers must use this rather than
|
||||
/// reconstructing `<dir>/<name>/SKILL.md`.
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Collection of discovered skills.
|
||||
@@ -70,7 +75,10 @@ impl SkillRegistry {
|
||||
let skill_path = entry.path().join("SKILL.md");
|
||||
match fs::read_to_string(&skill_path) {
|
||||
Ok(content) => match Self::parse_skill(&skill_path, &content) {
|
||||
Ok(skill) => registry.skills.push(skill),
|
||||
Ok(mut skill) => {
|
||||
skill.path = skill_path.clone();
|
||||
registry.skills.push(skill);
|
||||
}
|
||||
Err(reason) => registry.push_warning(format!(
|
||||
"Failed to parse {}: {reason}",
|
||||
skill_path.display()
|
||||
@@ -137,6 +145,9 @@ impl SkillRegistry {
|
||||
name,
|
||||
description,
|
||||
body,
|
||||
// Filled in by `discover` after parse succeeds; default to an
|
||||
// empty path so direct constructors (e.g. tests) compile.
|
||||
path: PathBuf::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,16 +207,19 @@ instructions when using a specific skill.\n\n",
|
||||
|
||||
let mut omitted = 0usize;
|
||||
for skill in skills {
|
||||
let path = skills_dir.join(&skill.name).join("SKILL.md");
|
||||
// Use the real on-disk path captured at discovery — the directory
|
||||
// name can differ from the frontmatter `name` for community
|
||||
// installs, in which case `<dir>/<name>/SKILL.md` would not exist
|
||||
// and the model would fail to open it.
|
||||
let description = truncate_for_prompt(&skill.description, MAX_SKILL_DESCRIPTION_CHARS);
|
||||
let line = if description.is_empty() {
|
||||
format!("- {}: (file: {})\n", skill.name, path.display())
|
||||
format!("- {}: (file: {})\n", skill.name, skill.path.display())
|
||||
} else {
|
||||
format!(
|
||||
"- {}: {} (file: {})\n",
|
||||
skill.name,
|
||||
description,
|
||||
path.display()
|
||||
skill.path.display()
|
||||
)
|
||||
};
|
||||
|
||||
@@ -336,6 +350,49 @@ mod tests {
|
||||
assert!(rendered.contains("### How to use skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() {
|
||||
// Regression: when a community-installed or manually-placed skill
|
||||
// lives in a directory whose name differs from its frontmatter
|
||||
// `name`, the rendered prompt must point to the real on-disk file
|
||||
// path, not <skills_dir>/<frontmatter-name>/SKILL.md (which does
|
||||
// not exist).
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"weird-dir-name",
|
||||
"---\nname: friendly-name\ndescription: drift case\n---\nbody",
|
||||
);
|
||||
|
||||
let rendered =
|
||||
crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
|
||||
.expect("skill context");
|
||||
|
||||
let real_path = tmpdir
|
||||
.path()
|
||||
.join("skills")
|
||||
.join("weird-dir-name")
|
||||
.join("SKILL.md")
|
||||
.display()
|
||||
.to_string();
|
||||
let stale_path = tmpdir
|
||||
.path()
|
||||
.join("skills")
|
||||
.join("friendly-name")
|
||||
.join("SKILL.md")
|
||||
.display()
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
rendered.contains(&real_path),
|
||||
"expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains(&stale_path),
|
||||
"rendered output must not invent a path under the frontmatter name:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_available_skills_context_returns_none_when_empty() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1017,6 +1017,50 @@ mod tests {
|
||||
assert_eq!(use_entry.command, "mcp_fs_read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_palette_marks_disabled_servers_visibly() {
|
||||
// The healthy/failed cases are covered above; disabled was the
|
||||
// remaining gap from #197's acceptance list. Disabled servers must
|
||||
// appear in the palette with a `[disabled]` state tag so users can
|
||||
// see them without opening the MCP manager.
|
||||
let snapshot = crate::mcp::McpManagerSnapshot {
|
||||
config_path: Path::new("mcp.json").to_path_buf(),
|
||||
config_exists: true,
|
||||
restart_required: false,
|
||||
servers: vec![crate::mcp::McpServerSnapshot {
|
||||
name: "muted".to_string(),
|
||||
enabled: false,
|
||||
required: false,
|
||||
transport: "stdio".to_string(),
|
||||
command_or_url: "node disabled.js".to_string(),
|
||||
connect_timeout: 10,
|
||||
execute_timeout: 60,
|
||||
read_timeout: 120,
|
||||
connected: false,
|
||||
error: None,
|
||||
tools: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
prompts: Vec::new(),
|
||||
}],
|
||||
};
|
||||
let entries = build_entries(
|
||||
Path::new("."),
|
||||
Path::new("."),
|
||||
Path::new("mcp.json"),
|
||||
Some(&snapshot),
|
||||
);
|
||||
|
||||
let muted = entries
|
||||
.iter()
|
||||
.find(|entry| entry.label == "mcp:muted")
|
||||
.expect("disabled server should still appear in the palette");
|
||||
assert!(
|
||||
muted.description.contains("[disabled]"),
|
||||
"expected `[disabled]` state tag in description, got: {}",
|
||||
muted.description
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_palette_emits_actions_not_raw_insertions() {
|
||||
let entries = vec![CommandPaletteEntry {
|
||||
|
||||
@@ -30,7 +30,11 @@ pub fn build_context_inspector_text(app: &App) -> String {
|
||||
let _ = writeln!(out, "Session Context");
|
||||
let _ = writeln!(out, "---------------");
|
||||
let _ = writeln!(out, "Model: {}", app.model);
|
||||
let _ = writeln!(out, "Workspace: {}", app.workspace.display());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"Workspace: {}",
|
||||
crate::utils::display_path(&app.workspace)
|
||||
);
|
||||
if let Some(session_id) = app.current_session_id.as_deref() {
|
||||
let _ = writeln!(out, "Session: {}", session_id);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("Workspace: {}", app.workspace.display()),
|
||||
format!("Workspace: {}", crate::utils::display_path(&app.workspace)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(super) fn format_shell_job_list(jobs: &[ShellJobSnapshot]) -> String {
|
||||
job.exit_code,
|
||||
task
|
||||
));
|
||||
lines.push(format!(" cwd: {}", job.cwd.display()));
|
||||
lines.push(format!(" cwd: {}", crate::utils::display_path(&job.cwd)));
|
||||
lines.push(format!(" cmd: {}", job.command));
|
||||
let tail = if !job.stderr_tail.trim().is_empty() {
|
||||
job.stderr_tail.trim()
|
||||
@@ -115,7 +115,7 @@ fn format_shell_job_detail(detail: &ShellJobDetail) -> String {
|
||||
format!("Job: {}", job.id),
|
||||
format!("Status: {}", status_label(&job.status, job.stale)),
|
||||
format!("Command: {}", job.command),
|
||||
format!("Cwd: {}", job.cwd.display()),
|
||||
format!("Cwd: {}", crate::utils::display_path(&job.cwd)),
|
||||
format!("Elapsed: {}", format_elapsed(job.elapsed_ms)),
|
||||
format!("Exit Code: {:?}", job.exit_code),
|
||||
format!("Stdin Available: {}", job.stdin_available),
|
||||
|
||||
@@ -489,7 +489,10 @@ fn format_task_detail(task: &TaskRecord) -> String {
|
||||
lines.push(format!("Status: {}", task_status_label(task.status)));
|
||||
lines.push(format!("Mode: {}", task.mode));
|
||||
lines.push(format!("Model: {}", task.model));
|
||||
lines.push(format!("Workspace: {}", task.workspace.display()));
|
||||
lines.push(format!(
|
||||
"Workspace: {}",
|
||||
crate::utils::display_path(&task.workspace)
|
||||
));
|
||||
if let Some(thread_id) = task.thread_id.as_ref() {
|
||||
lines.push(format!("Runtime Thread: {thread_id}"));
|
||||
}
|
||||
|
||||
@@ -186,6 +186,33 @@ pub fn url_encode(input: &str) -> String {
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Render a path for **user-facing display** with the home directory
|
||||
/// contracted to `~`. Use this in the TUI, doctor/setup stdout, and any
|
||||
/// other place a viewer might see the output (screenshot, video,
|
||||
/// pasted-into-issue help). On macOS/Linux the absolute path
|
||||
/// `/Users/<name>/...` or `/home/<name>/...` reveals the OS account name,
|
||||
/// which is often the same as a public handle — undesirable for users
|
||||
/// who share their terminal.
|
||||
///
|
||||
/// **Do not use** this for paths that get persisted (sessions, audit log)
|
||||
/// or sent to the LLM provider — those want full fidelity so they
|
||||
/// resolve correctly across processes.
|
||||
#[must_use]
|
||||
pub fn display_path(path: &Path) -> String {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return path.display().to_string();
|
||||
};
|
||||
if let Ok(rest) = path.strip_prefix(&home) {
|
||||
if rest.as_os_str().is_empty() {
|
||||
return "~".to_string();
|
||||
}
|
||||
// Render with the platform-correct separator after the tilde.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
return format!("~{sep}{}", rest.display());
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
/// Estimate the total character count across message content blocks.
|
||||
#[must_use]
|
||||
pub fn estimate_message_chars(messages: &[Message]) -> usize {
|
||||
@@ -205,3 +232,81 @@ pub fn estimate_message_chars(messages: &[Message]) -> usize {
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
// Tests below set `HOME` to drive `dirs::home_dir()`, which is honored on
|
||||
// Unix but not on Windows (which reads `USERPROFILE` first). The
|
||||
// `display_path` contraction logic itself is platform-identical — it
|
||||
// delegates to `dirs::home_dir()`. Gate to `cfg(unix)` so we cover the
|
||||
// behavior on the platform whose env-var contract matches the test
|
||||
// driver, instead of writing platform-specific test scaffolding for a
|
||||
// pure abstraction.
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::display_path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Save and restore $HOME inside one test so a panic anywhere can't
|
||||
/// poison sibling tests that read the env var.
|
||||
fn with_home<R>(home: &str, f: impl FnOnce() -> R) -> R {
|
||||
let prev = std::env::var_os("HOME");
|
||||
// SAFETY: tests in this crate are run single-threaded with respect
|
||||
// to env-var mutation by the integration harness, and we restore
|
||||
// immediately after the closure.
|
||||
unsafe { std::env::set_var("HOME", home) };
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
match prev {
|
||||
Some(v) => unsafe { std::env::set_var("HOME", v) },
|
||||
None => unsafe { std::env::remove_var("HOME") },
|
||||
}
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(p) => std::panic::resume_unwind(p),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_contracts_home_prefix() {
|
||||
with_home("/Users/alice", || {
|
||||
assert_eq!(
|
||||
display_path(&PathBuf::from("/Users/alice/projects/foo")),
|
||||
format!(
|
||||
"~{}projects{}foo",
|
||||
std::path::MAIN_SEPARATOR,
|
||||
std::path::MAIN_SEPARATOR
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_returns_bare_tilde_for_home_itself() {
|
||||
with_home("/Users/alice", || {
|
||||
assert_eq!(display_path(&PathBuf::from("/Users/alice")), "~");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_leaves_unrelated_paths_alone() {
|
||||
with_home("/Users/alice", || {
|
||||
// Different user — must not get rewritten or share the tilde.
|
||||
assert_eq!(
|
||||
display_path(&PathBuf::from("/Users/bob/Code")),
|
||||
"/Users/bob/Code".to_string()
|
||||
);
|
||||
// System path must stay absolute.
|
||||
assert_eq!(display_path(&PathBuf::from("/etc/hosts")), "/etc/hosts");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_does_not_match_username_prefix() {
|
||||
// Regression guard: a directory named like the user's home
|
||||
// *prefix* but not under it must not get rewritten.
|
||||
with_home("/Users/alice", || {
|
||||
assert_eq!(
|
||||
display_path(&PathBuf::from("/Users/alice2/work")),
|
||||
"/Users/alice2/work"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,28 @@ use std::fs;
|
||||
#[allow(dead_code)]
|
||||
mod tool_parser;
|
||||
|
||||
const ENGINE_SRC: &str = include_str!("../src/core/engine.rs");
|
||||
// `engine.rs` was decomposed into submodules under `core/engine/`. The
|
||||
// protocol-scrubbing strings the tests below assert on are now spread
|
||||
// across `engine.rs` and several `engine/*.rs` files. We compile-time
|
||||
// include each so a contributor moving a marker into a sibling submodule
|
||||
// does not silently break these regression checks.
|
||||
const ENGINE_SOURCES: &[&str] = &[
|
||||
include_str!("../src/core/engine.rs"),
|
||||
include_str!("../src/core/engine/streaming.rs"),
|
||||
include_str!("../src/core/engine/turn_loop.rs"),
|
||||
include_str!("../src/core/engine/dispatch.rs"),
|
||||
include_str!("../src/core/engine/tool_setup.rs"),
|
||||
include_str!("../src/core/engine/tool_execution.rs"),
|
||||
include_str!("../src/core/engine/tool_catalog.rs"),
|
||||
include_str!("../src/core/engine/context.rs"),
|
||||
include_str!("../src/core/engine/approval.rs"),
|
||||
include_str!("../src/core/engine/capacity_flow.rs"),
|
||||
include_str!("../src/core/engine/lsp_hooks.rs"),
|
||||
];
|
||||
|
||||
fn any_engine_source_contains(needle: &str) -> bool {
|
||||
ENGINE_SOURCES.iter().any(|src| src.contains(needle))
|
||||
}
|
||||
|
||||
const EXPECTED_START_MARKERS: &[&str] = &[
|
||||
"[TOOL_CALL]",
|
||||
@@ -46,9 +67,10 @@ fn engine_keeps_known_fake_wrapper_start_markers() {
|
||||
for marker in EXPECTED_START_MARKERS {
|
||||
let needle = format!("\"{marker}\"");
|
||||
assert!(
|
||||
ENGINE_SRC.contains(&needle),
|
||||
"engine.rs no longer mentions start marker `{marker}` — protocol \
|
||||
scrubbing may have regressed. Searched for {needle:?}."
|
||||
any_engine_source_contains(&needle),
|
||||
"no engine source file still mentions start marker `{marker}` — \
|
||||
protocol scrubbing may have regressed. Searched for {needle:?} \
|
||||
across engine.rs and engine/* submodules."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,9 +80,10 @@ fn engine_keeps_known_fake_wrapper_end_markers() {
|
||||
for marker in EXPECTED_END_MARKERS {
|
||||
let needle = format!("\"{marker}\"");
|
||||
assert!(
|
||||
ENGINE_SRC.contains(&needle),
|
||||
"engine.rs no longer mentions end marker `{marker}` — protocol \
|
||||
scrubbing may have regressed. Searched for {needle:?}."
|
||||
any_engine_source_contains(&needle),
|
||||
"no engine source file still mentions end marker `{marker}` — \
|
||||
protocol scrubbing may have regressed. Searched for {needle:?} \
|
||||
across engine.rs and engine/* submodules."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,18 +94,18 @@ fn engine_marker_counts_stay_paired() {
|
||||
// filter able to enter tool-call mode without ever leaving it. Lock the
|
||||
// count to whatever the constants currently declare.
|
||||
assert_eq!(EXPECTED_START_MARKERS.len(), EXPECTED_END_MARKERS.len());
|
||||
assert!(ENGINE_SRC.contains("TOOL_CALL_START_MARKERS"));
|
||||
assert!(ENGINE_SRC.contains("TOOL_CALL_END_MARKERS"));
|
||||
assert!(any_engine_source_contains("TOOL_CALL_START_MARKERS"));
|
||||
assert!(any_engine_source_contains("TOOL_CALL_END_MARKERS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_emits_compact_fake_wrapper_notice() {
|
||||
assert!(
|
||||
ENGINE_SRC.contains("FAKE_WRAPPER_NOTICE"),
|
||||
"engine.rs no longer references the protocol-recovery notice constant"
|
||||
any_engine_source_contains("FAKE_WRAPPER_NOTICE"),
|
||||
"no engine source file references the protocol-recovery notice constant"
|
||||
);
|
||||
assert!(
|
||||
ENGINE_SRC.contains("API tool channel"),
|
||||
any_engine_source_contains("API tool channel"),
|
||||
"the protocol-recovery notice should mention the API tool channel"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,17 @@ Set `DEEPSEEK_TUI_VERSION` to the npm package version you are verifying for that
|
||||
The CI workflow runs the same tarball install + delegated-entrypoint smoke test
|
||||
on Linux, macOS, and Windows.
|
||||
|
||||
After publishing, prove the release is visible in both registries:
|
||||
|
||||
```bash
|
||||
./scripts/release/check-published.sh X.Y.Z
|
||||
```
|
||||
|
||||
Do not mark a Rust release complete until that command sees `deepseek-tui@X.Y.Z`
|
||||
on npm and every `deepseek-*` crate at `X.Y.Z` on crates.io. For a rare
|
||||
npm packaging-only release, run with `--allow-npm-binary-mismatch` and keep the
|
||||
release notes explicit that no new Rust binary version shipped.
|
||||
|
||||
## Rust Crates Release
|
||||
|
||||
1. Update the workspace version in [Cargo.toml](../Cargo.toml).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deepseek-tui",
|
||||
"version": "0.8.2",
|
||||
"deepseekBinaryVersion": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"deepseekBinaryVersion": "0.8.3",
|
||||
"description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/../.." && pwd)"
|
||||
# shellcheck source=scripts/release/crates.sh
|
||||
source "${script_dir}/crates.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/release/check-published.sh [--allow-npm-binary-mismatch] [VERSION]
|
||||
|
||||
Verifies that a release version is visible on both npm and crates.io.
|
||||
Defaults VERSION to the workspace version in Cargo.toml.
|
||||
|
||||
Use --allow-npm-binary-mismatch only for npm packaging-only releases where
|
||||
the npm package intentionally points at an older GitHub binary release.
|
||||
EOF
|
||||
}
|
||||
|
||||
allow_npm_binary_mismatch=0
|
||||
version=""
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--allow-npm-binary-mismatch)
|
||||
allow_npm_binary_mismatch=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [[ -n "${version}" ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
version="$1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
cd "${repo_root}"
|
||||
|
||||
if [[ -z "${version}" ]]; then
|
||||
version="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
|
||||
fi
|
||||
|
||||
if [[ -z "${version}" ]]; then
|
||||
echo "Could not determine release version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail=0
|
||||
|
||||
echo "Checking published release ${version}..."
|
||||
|
||||
if npm_version="$(npm view "deepseek-tui@${version}" version 2>/dev/null)"; then
|
||||
echo "npm deepseek-tui@${npm_version} is published."
|
||||
else
|
||||
echo "npm deepseek-tui@${version} is not published." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if npm_binary_version="$(npm view "deepseek-tui@${version}" deepseekBinaryVersion 2>/dev/null)"; then
|
||||
if [[ "${npm_binary_version}" == "${version}" ]]; then
|
||||
echo "npm deepseekBinaryVersion=${npm_binary_version}."
|
||||
elif [[ "${allow_npm_binary_mismatch}" == "1" ]]; then
|
||||
echo "npm deepseekBinaryVersion=${npm_binary_version} (allowed packaging-only mismatch)."
|
||||
else
|
||||
echo "npm deepseekBinaryVersion=${npm_binary_version}, expected ${version}." >&2
|
||||
fail=1
|
||||
fi
|
||||
elif [[ "${allow_npm_binary_mismatch}" == "1" ]]; then
|
||||
echo "npm deepseekBinaryVersion is absent (allowed packaging-only mismatch)."
|
||||
else
|
||||
echo "npm deepseekBinaryVersion is absent for deepseek-tui@${version}." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
for crate in "${release_crates[@]}"; do
|
||||
if curl -fsSL "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1; then
|
||||
echo "crates.io ${crate}@${version} is published."
|
||||
else
|
||||
echo "crates.io ${crate}@${version} is not published." >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${fail}" == "0" ]]; then
|
||||
echo "Published release OK: npm deepseek-tui@${version} and ${#release_crates[@]} crates are visible."
|
||||
fi
|
||||
|
||||
exit "${fail}"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Crates published for each DeepSeek TUI release, in dependency order.
|
||||
release_crates=(
|
||||
deepseek-secrets
|
||||
deepseek-config
|
||||
deepseek-protocol
|
||||
deepseek-state
|
||||
deepseek-agent
|
||||
deepseek-execpolicy
|
||||
deepseek-hooks
|
||||
deepseek-mcp
|
||||
deepseek-tools
|
||||
deepseek-core
|
||||
deepseek-app-server
|
||||
deepseek-tui-core
|
||||
deepseek-tui-cli
|
||||
deepseek-tui
|
||||
)
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/release/crates.sh
|
||||
source "${script_dir}/crates.sh"
|
||||
|
||||
mode="${1:-dry-run}"
|
||||
case "${mode}" in
|
||||
dry-run|publish) ;;
|
||||
@@ -10,42 +14,22 @@ case "${mode}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
packages=(
|
||||
deepseek-secrets
|
||||
deepseek-config
|
||||
deepseek-protocol
|
||||
deepseek-state
|
||||
deepseek-agent
|
||||
deepseek-execpolicy
|
||||
deepseek-hooks
|
||||
deepseek-mcp
|
||||
deepseek-tools
|
||||
deepseek-core
|
||||
deepseek-app-server
|
||||
deepseek-tui-core
|
||||
deepseek-tui-cli
|
||||
deepseek-tui
|
||||
)
|
||||
|
||||
workspace_version="$(
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
metadata = json.loads(
|
||||
subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"])
|
||||
)
|
||||
workspace_members = set(metadata["workspace_members"])
|
||||
for pkg in metadata["packages"]:
|
||||
if pkg["id"] in workspace_members:
|
||||
print(pkg["version"])
|
||||
break
|
||||
PY
|
||||
)"
|
||||
packages=("${release_crates[@]}")
|
||||
|
||||
workspace_version=""
|
||||
workspace_deepseek_packages=()
|
||||
while IFS= read -r workspace_package; do
|
||||
workspace_deepseek_packages+=("${workspace_package}")
|
||||
workspace_package_dep_flags=()
|
||||
|
||||
while IFS=$'\t' read -r kind name value; do
|
||||
case "${kind}" in
|
||||
version)
|
||||
workspace_version="${name}"
|
||||
;;
|
||||
crate)
|
||||
workspace_deepseek_packages+=("${name}")
|
||||
workspace_package_dep_flags+=("${value}")
|
||||
;;
|
||||
esac
|
||||
done < <(
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
@@ -55,12 +39,34 @@ metadata = json.loads(
|
||||
subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"])
|
||||
)
|
||||
workspace_members = set(metadata["workspace_members"])
|
||||
for pkg in sorted(metadata["packages"], key=lambda item: item["name"]):
|
||||
if pkg["id"] in workspace_members and pkg["name"].startswith("deepseek-"):
|
||||
print(pkg["name"])
|
||||
workspace_packages = [
|
||||
pkg for pkg in metadata["packages"] if pkg["id"] in workspace_members
|
||||
]
|
||||
workspace_by_name = {pkg["name"]: pkg for pkg in workspace_packages}
|
||||
|
||||
versions = sorted({pkg["version"] for pkg in workspace_packages})
|
||||
if not versions:
|
||||
raise SystemExit("workspace has no packages")
|
||||
if len(versions) != 1:
|
||||
raise SystemExit(f"workspace packages have mixed versions: {', '.join(versions)}")
|
||||
print(f"version\t{versions[0]}\t")
|
||||
|
||||
for pkg in sorted(workspace_packages, key=lambda item: item["name"]):
|
||||
if not pkg["name"].startswith("deepseek-"):
|
||||
continue
|
||||
has_workspace_dep = any(
|
||||
dep.get("path") and dep["name"] in workspace_by_name
|
||||
for dep in pkg["dependencies"]
|
||||
)
|
||||
print(f"crate\t{pkg['name']}\t{1 if has_workspace_dep else 0}")
|
||||
PY
|
||||
)
|
||||
|
||||
if [[ -z "${workspace_version}" ]]; then
|
||||
echo "Could not determine workspace version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing_packages=()
|
||||
for workspace_package in "${workspace_deepseek_packages[@]}"; do
|
||||
found=0
|
||||
@@ -101,26 +107,16 @@ fi
|
||||
|
||||
package_has_workspace_deps() {
|
||||
local package_name="$1"
|
||||
python3 - "${package_name}" <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
local index
|
||||
for ((index = 0; index < ${#workspace_deepseek_packages[@]}; index += 1)); do
|
||||
if [[ "${workspace_deepseek_packages[$index]}" == "${package_name}" ]]; then
|
||||
[[ "${workspace_package_dep_flags[$index]}" == "1" ]]
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
package_name = sys.argv[1]
|
||||
metadata = json.loads(
|
||||
subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"])
|
||||
)
|
||||
workspace_ids = set(metadata["workspace_members"])
|
||||
workspace_packages = {
|
||||
pkg["name"]: pkg for pkg in metadata["packages"] if pkg["id"] in workspace_ids
|
||||
}
|
||||
package = workspace_packages[package_name]
|
||||
has_workspace_dep = any(
|
||||
dep.get("path") and dep["name"] in workspace_packages
|
||||
for dep in package["dependencies"]
|
||||
)
|
||||
print("1" if has_workspace_dep else "0")
|
||||
PY
|
||||
echo "Unknown workspace crate: ${package_name}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
crate_version_exists() {
|
||||
@@ -149,7 +145,7 @@ wait_for_crate_version() {
|
||||
for package in "${packages[@]}"; do
|
||||
echo "::group::${mode} ${package}"
|
||||
if [[ "${mode}" == "dry-run" ]]; then
|
||||
if [[ "$(package_has_workspace_deps "${package}")" == "1" ]]; then
|
||||
if package_has_workspace_deps "${package}"; then
|
||||
cargo package --allow-dirty --locked --list -p "${package}" >/dev/null
|
||||
echo "Verified package contents for ${package}; full crates.io dry-run requires workspace dependencies at ${workspace_version} to be published first."
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user