diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 26eff653..c60b6abe 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -949,6 +949,43 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage { } } +impl DeepSeekClient { + /// Call the DeepSeek `/beta/completions` FIM endpoint. + /// + /// Returns the generated text (the "middle" between `prompt` and `suffix`). + pub async fn fim_completion( + &self, + model: &str, + prompt: &str, + suffix: &str, + max_tokens: u32, +) -> anyhow::Result { + let url = api_url(&self.base_url, "beta/completions"); + let body = json!({ + "model": model, + "prompt": prompt, + "suffix": suffix, + "max_tokens": max_tokens, + }); + let response = self + .send_with_retry(|| self.http_client.post(&url).json(&body)) + .await?; + let status = response.status(); + if !status.is_success() { + let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; + anyhow::bail!("FIM API error: HTTP {status}: {error_text}"); + } + let response_text = response.text().await.unwrap_or_default(); + let value: serde_json::Value = + serde_json::from_str(&response_text).context("Failed to parse FIM API response")?; + let text = value + .pointer("/choices/0/text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow::anyhow!("FIM response missing choices[0].text"))?; + Ok(text.to_string()) + } +} + mod chat; mod responses; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 5fe31ccb..87e046e6 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -711,6 +711,9 @@ pub struct Config { pub mcp_config_path: Option, pub notes_path: Option, pub memory_path: Option, + /// When true, set `tool_choice: "required"` in all API requests so the + /// model MUST call a tool on every step (V4 strict tool-following mode). + pub strict_tool_mode: Option, /// Additional system-prompt sources concatenated in declared order /// (#454). Paths are expanded via `expand_path` so `~` and env /// vars work. Project config overrides user config (replace, not @@ -2034,6 +2037,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { per_model: override_cfg.context.per_model.or(base.context.per_model), }, subagents: override_cfg.subagents.or(base.subagents), + strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode), runtime_api: override_cfg.runtime_api.or(base.runtime_api), } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 2cd07872..26202849 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -142,6 +142,9 @@ pub struct EngineConfig { /// consulted when `memory_enabled` is `true`. pub memory_path: PathBuf, pub goal_objective: Option, + /// When true, force `tool_choice: "required"` so the model always calls + /// a tool on every turn step (V4 strict tool-following mode). + pub strict_tool_mode: bool, } impl Default for EngineConfig { @@ -171,6 +174,7 @@ impl Default for EngineConfig { subagent_model_overrides: HashMap::new(), memory_enabled: false, memory_path: PathBuf::from("./memory.md"), + strict_tool_mode: false, goal_objective: None, } } diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 0d33696e..67ebf236 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -33,6 +33,7 @@ impl Engine { 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_fim_tool(self.deepseek_client.clone(), self.session.model.clone()) .with_user_input_tool() .with_parallel_tool(); diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 7c27fced..98ae6502 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -242,7 +242,11 @@ impl Engine { system: self.session.system_prompt.clone(), tools: active_tools.clone(), tool_choice: if active_tools.is_some() { - Some(json!({ "type": "auto" })) + if self.config.strict_tool_mode { + Some(json!("required")) + } else { + Some(json!({ "type": "auto" })) + } } else { None }, diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f2d2e967..6e3c8ba8 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3763,6 +3763,7 @@ async fn run_exec_agent( subagent_model_overrides: config.subagent_model_overrides(), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), + strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: None, }; diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 1fb28cd2..872741e6 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1807,6 +1807,7 @@ impl RuntimeThreadManager { subagent_model_overrides: self.config.subagent_model_overrides(), memory_enabled: self.config.memory_enabled(), memory_path: self.config.memory_path(), + strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false), goal_objective: None, }; diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs new file mode 100644 index 00000000..b375a508 --- /dev/null +++ b/crates/tui/src/tools/fim.rs @@ -0,0 +1,190 @@ +//! FIM (Fill-in-the-Middle) edit tool. +//! +//! Reads a file, finds `prefix_anchor` and `suffix_anchor`, calls the +//! DeepSeek `/beta/completions` FIM endpoint, and writes the generated +//! middle content back into the file. + +use std::fs; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use thiserror::Error; + +use crate::client::DeepSeekClient; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, + optional_u64, required_str, +}; + +/// Result of a FIM edit operation +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct FimEditResult { + pub success: bool, + pub path: String, + pub generated_text: String, + pub prefix_end: usize, + pub suffix_start: usize, + pub message: String, +} + +/// Tool for performing Fill-in-the-Middle edits via the DeepSeek FIM API. +pub struct FimEditTool { + pub client: Option, + pub model: String, +} + +impl FimEditTool { + #[must_use] + pub fn new(client: Option, model: String) -> Self { + Self { client, model } + } +} + +// === Errors === + +#[derive(Debug, Error)] +enum FimError { + #[error("Prefix anchor not found in file: '{0}'")] + PrefixNotFound(String), + #[error("Suffix anchor not found after prefix anchor: '{0}'")] + SuffixNotFound(String), + #[error("Prefix and suffix anchors overlap (suffix starts at {0}, prefix ends at {1})")] + AnchorsOverlap(usize, usize), + #[error("FIM API call failed: {0}")] + ApiFailed(String), +} + +#[async_trait] +impl ToolSpec for FimEditTool { + fn name(&self) -> &'static str { + "fim_edit" + } + + fn description(&self) -> &'static str { + "Edit a file using Fill-in-the-Middle (FIM) completion. Provide a file path, \ + prefix_anchor (text that appears before the section to replace), and \ + suffix_anchor (text that appears after the section to replace). The tool \ + calls DeepSeek's FIM endpoint to generate replacement content." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to edit (relative to workspace)" + }, + "prefix_anchor": { + "type": "string", + "description": "Text anchor marking the end of the prefix. Everything up to and including this anchor is kept as-is before the generated middle." + }, + "suffix_anchor": { + "type": "string", + "description": "Text anchor marking the start of the suffix. Everything from this anchor onward is kept as-is after the generated middle." + }, + "max_tokens": { + "type": "integer", + "description": "Maximum tokens to generate (default: 1024)" + } + }, + "required": ["path", "prefix_anchor", "suffix_anchor"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::ReadOnly, + ToolCapability::WritesFiles, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Suggest + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let path = required_str(&input, "path")?; + let prefix_anchor = required_str(&input, "prefix_anchor")?; + let suffix_anchor = required_str(&input, "suffix_anchor")?; + let max_tokens = optional_u64(&input, "max_tokens", 1024); + + // 1. Read the file + let resolved = context.resolve_path(path)?; + let content = fs::read_to_string(&resolved).map_err(|e| { + ToolError::execution_failed(format!("Failed to read {}: {}", resolved.display(), e)) + })?; + + // 2. Find prefix anchor + let prefix_pos = content.find(prefix_anchor).ok_or_else(|| { + ToolError::execution_failed( + FimError::PrefixNotFound(prefix_anchor.to_string()).to_string(), + ) + })?; + let prefix_end = prefix_pos + prefix_anchor.len(); + + // 3. Find suffix anchor (after prefix anchor) + let suffix_pos = content[prefix_end..].find(suffix_anchor).ok_or_else(|| { + ToolError::execution_failed( + FimError::SuffixNotFound(suffix_anchor.to_string()).to_string(), + ) + })?; + let suffix_start = prefix_end + suffix_pos; + + // 4. Validate anchors don't overlap + if suffix_start < prefix_end { + return Err(ToolError::execution_failed( + FimError::AnchorsOverlap(suffix_start, prefix_end).to_string(), + )); + } + + // 5. Extract prefix and suffix for the FIM API + let fim_prompt = content[..prefix_end].to_string(); + let fim_suffix = content[suffix_start..].to_string(); + + // 6. Call FIM API + let generated_text = match self.client.as_ref() { + Some(client) => client + .fim_completion(&self.model, &fim_prompt, &fim_suffix, max_tokens as u32) + .await + .map_err(|e| { + ToolError::execution_failed(FimError::ApiFailed(e.to_string()).to_string()) + })?, + None => { + return Err(ToolError::execution_failed( + "FIM API client not available".to_string(), + )); + } + }; + + // 7. Build the new content and write it back + let generated_len = generated_text.len(); + let new_content = format!("{}{}{}", fim_prompt, generated_text, fim_suffix); + fs::write(&resolved, &new_content).map_err(|e| { + ToolError::execution_failed(format!( + "Failed to write {}: {}", + resolved.display(), + e + )) + })?; + + let result = FimEditResult { + success: true, + path: path.to_string(), + generated_text, + prefix_end, + suffix_start, + message: format!( + "FIM edit applied to `{}`. Generated {} chars between prefix_anchor end (byte {}) and suffix_anchor start (byte {}).", + path, + generated_len, + prefix_end, + suffix_start, + ), + }; + + ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string())) + } +} diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index cbb0911d..d1affc11 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -10,6 +10,7 @@ pub mod file_search; pub mod finance; pub mod fetch_url; +pub mod fim; pub mod git; pub mod git_history; pub mod github; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 5bbca679..3bb48b6f 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -513,6 +513,13 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(NoteTool)) } + /// Include the FIM (Fill-in-the-Middle) edit tool. + #[must_use] + pub fn with_fim_tool(self, client: Option, model: String) -> Self { + use super::fim::FimEditTool; + self.with_tool(Arc::new(FimEditTool::new(client, model))) + } + /// Include the `remember` tool — model-callable bullet-add into the /// user memory file (#489). Only register when the user has opted /// in to the memory feature; without that, the tool would surface diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c76d92ac..f0f3bcba 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -541,6 +541,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { subagent_model_overrides: config.subagent_model_overrides(), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), + strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: app.goal.goal_objective.clone(), } }