feat(tools): FIM edit tool for V4 /beta endpoint (#668)

This commit is contained in:
Hunter Bown
2026-05-05 00:12:17 -05:00
11 changed files with 252 additions and 1 deletions
+37
View File
@@ -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<String> {
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;
+4
View File
@@ -711,6 +711,9 @@ pub struct Config {
pub mcp_config_path: Option<String>,
pub notes_path: Option<String>,
pub memory_path: Option<String>,
/// 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<bool>,
/// 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),
}
}
+4
View File
@@ -142,6 +142,9 @@ pub struct EngineConfig {
/// consulted when `memory_enabled` is `true`.
pub memory_path: PathBuf,
pub goal_objective: Option<String>,
/// 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,
}
}
+1
View File
@@ -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();
+5 -1
View File
@@ -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
},
+1
View File
@@ -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,
};
+1
View File
@@ -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,
};
+190
View File
@@ -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<DeepSeekClient>,
pub model: String,
}
impl FimEditTool {
#[must_use]
pub fn new(client: Option<DeepSeekClient>, 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<ToolCapability> {
vec![
ToolCapability::ReadOnly,
ToolCapability::WritesFiles,
ToolCapability::RequiresApproval,
]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Suggest
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
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()))
}
}
+1
View File
@@ -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;
+7
View File
@@ -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<DeepSeekClient>, 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
+1
View File
@@ -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(),
}
}