fix: preserve DeepSeek V4 reasoning tool turns

This commit is contained in:
Hunter Bown
2026-04-24 00:01:10 -05:00
parent ffa75f07e5
commit d89b33330f
19 changed files with 681 additions and 96 deletions
+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.2] - 2026-04-24
### Fixed
- DeepSeek V4 thinking-mode tool turns now checkpoint the engine's authoritative API transcript, including assistant `reasoning_content` on reasoning-to-tool-call turns with no visible assistant text.
- Chat Completions request building now drops stale V4 tool-call rounds that are missing required `reasoning_content`, preventing old corrupted checkpoints from triggering DeepSeek HTTP 400 replay errors.
- Web search now falls back to Bing HTML results when DuckDuckGo returns a bot challenge or otherwise yields no parseable results.
## [0.4.1] - 2026-04-24
### Fixed
Generated
+13 -13
View File
@@ -806,7 +806,7 @@ dependencies = [
[[package]]
name = "deepseek-agent"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"deepseek-config",
"serde",
@@ -814,7 +814,7 @@ dependencies = [
[[package]]
name = "deepseek-app-server"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"axum",
@@ -837,7 +837,7 @@ dependencies = [
[[package]]
name = "deepseek-config"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"dirs",
@@ -848,7 +848,7 @@ dependencies = [
[[package]]
name = "deepseek-core"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"chrono",
@@ -867,7 +867,7 @@ dependencies = [
[[package]]
name = "deepseek-execpolicy"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"deepseek-protocol",
@@ -876,7 +876,7 @@ dependencies = [
[[package]]
name = "deepseek-hooks"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"async-trait",
@@ -890,7 +890,7 @@ dependencies = [
[[package]]
name = "deepseek-mcp"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"deepseek-protocol",
@@ -900,7 +900,7 @@ dependencies = [
[[package]]
name = "deepseek-protocol"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"serde",
"serde_json",
@@ -908,7 +908,7 @@ dependencies = [
[[package]]
name = "deepseek-state"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"chrono",
@@ -920,7 +920,7 @@ dependencies = [
[[package]]
name = "deepseek-tools"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"async-trait",
@@ -933,7 +933,7 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"arboard",
@@ -987,7 +987,7 @@ dependencies = [
[[package]]
name = "deepseek-tui-cli"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"chrono",
@@ -1005,7 +1005,7 @@ dependencies = [
[[package]]
name = "deepseek-tui-core"
version = "0.4.1"
version = "0.4.2"
[[package]]
name = "deranged"
+1 -1
View File
@@ -18,7 +18,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
resolver = "2"
[workspace.package]
version = "0.4.1"
version = "0.4.2"
edition = "2024"
license = "MIT"
repository = "https://github.com/Hmbown/DeepSeek-TUI"
+1 -1
View File
@@ -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.4.1" }
deepseek-config = { path = "../config", version = "0.4.2" }
serde.workspace = true
+9 -9
View File
@@ -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.4.1" }
deepseek-config = { path = "../config", version = "0.4.1" }
deepseek-core = { path = "../core", version = "0.4.1" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.1" }
deepseek-hooks = { path = "../hooks", version = "0.4.1" }
deepseek-mcp = { path = "../mcp", version = "0.4.1" }
deepseek-protocol = { path = "../protocol", version = "0.4.1" }
deepseek-state = { path = "../state", version = "0.4.1" }
deepseek-tools = { path = "../tools", version = "0.4.1" }
deepseek-agent = { path = "../agent", version = "0.4.2" }
deepseek-config = { path = "../config", version = "0.4.2" }
deepseek-core = { path = "../core", version = "0.4.2" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.2" }
deepseek-hooks = { path = "../hooks", version = "0.4.2" }
deepseek-mcp = { path = "../mcp", version = "0.4.2" }
deepseek-protocol = { path = "../protocol", version = "0.4.2" }
deepseek-state = { path = "../state", version = "0.4.2" }
deepseek-tools = { path = "../tools", version = "0.4.2" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+6 -6
View File
@@ -14,12 +14,12 @@ path = "src/main.rs"
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
deepseek-agent = { path = "../agent", version = "0.4.1" }
deepseek-app-server = { path = "../app-server", version = "0.4.1" }
deepseek-config = { path = "../config", version = "0.4.1" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.1" }
deepseek-mcp = { path = "../mcp", version = "0.4.1" }
deepseek-state = { path = "../state", version = "0.4.1" }
deepseek-agent = { path = "../agent", version = "0.4.2" }
deepseek-app-server = { path = "../app-server", version = "0.4.2" }
deepseek-config = { path = "../config", version = "0.4.2" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.2" }
deepseek-mcp = { path = "../mcp", version = "0.4.2" }
deepseek-state = { path = "../state", version = "0.4.2" }
chrono.workspace = true
serde_json.workspace = true
tokio.workspace = true
+8 -8
View File
@@ -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.4.1" }
deepseek-config = { path = "../config", version = "0.4.1" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.1" }
deepseek-hooks = { path = "../hooks", version = "0.4.1" }
deepseek-mcp = { path = "../mcp", version = "0.4.1" }
deepseek-protocol = { path = "../protocol", version = "0.4.1" }
deepseek-state = { path = "../state", version = "0.4.1" }
deepseek-tools = { path = "../tools", version = "0.4.1" }
deepseek-agent = { path = "../agent", version = "0.4.2" }
deepseek-config = { path = "../config", version = "0.4.2" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.2" }
deepseek-hooks = { path = "../hooks", version = "0.4.2" }
deepseek-mcp = { path = "../mcp", version = "0.4.2" }
deepseek-protocol = { path = "../protocol", version = "0.4.2" }
deepseek-state = { path = "../state", version = "0.4.2" }
deepseek-tools = { path = "../tools", version = "0.4.2" }
serde_json.workspace = true
tokio.workspace = true
uuid.workspace = true
+1 -1
View File
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.4.1" }
deepseek-protocol = { path = "../protocol", version = "0.4.2" }
serde.workspace = true
+1 -1
View File
@@ -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.4.1" }
deepseek-protocol = { path = "../protocol", version = "0.4.2" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
+1 -1
View File
@@ -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.4.1" }
deepseek-protocol = { path = "../protocol", version = "0.4.2" }
serde.workspace = true
serde_json.workspace = true
+1 -1
View File
@@ -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.4.1" }
deepseek-protocol = { path = "../protocol", version = "0.4.2" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+187 -24
View File
@@ -638,8 +638,7 @@ impl DeepSeekClient {
}
async fn create_message_chat(&self, request: &MessageRequest) -> Result<MessageResponse> {
let messages =
build_chat_messages(request.system.as_ref(), &request.messages, &request.model);
let messages = build_chat_messages_for_request(request);
let mut body = json!({
"model": request.model,
"messages": messages,
@@ -761,8 +760,7 @@ impl LlmClient for DeepSeekClient {
async fn create_message_stream(&self, request: MessageRequest) -> Result<StreamEventBox> {
// Try true SSE streaming via chat completions (widely supported)
let messages =
build_chat_messages(request.system.as_ref(), &request.messages, &request.model);
let messages = build_chat_messages_for_request(&request);
let mut body = json!({
"model": request.model,
"messages": messages,
@@ -1308,13 +1306,36 @@ fn parse_responses_message(payload: &Value) -> Result<MessageResponse> {
// === Chat Completions Helpers ===
#[cfg(test)]
fn build_chat_messages(
system: Option<&SystemPrompt>,
messages: &[Message],
model: &str,
) -> Vec<Value> {
build_chat_messages_with_reasoning(
system,
messages,
model,
should_replay_reasoning_content(model, None),
)
}
fn build_chat_messages_for_request(request: &MessageRequest) -> Vec<Value> {
build_chat_messages_with_reasoning(
request.system.as_ref(),
&request.messages,
&request.model,
should_replay_reasoning_content(&request.model, request.reasoning_effort.as_deref()),
)
}
fn build_chat_messages_with_reasoning(
system: Option<&SystemPrompt>,
messages: &[Message],
_model: &str,
include_reasoning: bool,
) -> Vec<Value> {
let mut out = Vec::new();
let include_reasoning = requires_reasoning_content(model);
let mut pending_tool_calls: HashSet<String> = HashSet::new();
if let Some(instructions) = system_to_instructions(system.cloned())
@@ -1387,10 +1408,26 @@ fn build_chat_messages(
let content = text_parts.join("\n");
let reasoning_content = thinking_parts.join("\n");
let has_text = !content.trim().is_empty();
let has_tool_calls = !tool_calls.is_empty();
let mut has_tool_calls = !tool_calls.is_empty();
let include_reasoning_for_turn = include_reasoning && has_tool_calls;
let has_reasoning = include_reasoning_for_turn && !reasoning_content.trim().is_empty();
// DeepSeek thinking-mode tool turns are stateful within the
// stateless Chat Completions transcript: if an assistant performed
// a tool call, its `reasoning_content` must be replayed in every
// later request. Older checkpoints could lose that field because
// the UI display stream had no visible text block. Do not forward
// those malformed tool calls; dropping the stale tool round is
// better than guaranteeing a provider-side 400.
if include_reasoning_for_turn && !has_reasoning {
logging::warn(
"Dropping DeepSeek tool_calls with missing reasoning_content from assistant message",
);
tool_calls.clear();
tool_call_ids.clear();
has_tool_calls = false;
}
// DeepSeek rejects assistant messages where both `content` and
// `tool_calls` are missing/null. Skip such entries even if they
// carry reasoning-only metadata unless we can send a non-null
@@ -1617,6 +1654,22 @@ fn requires_reasoning_content(model: &str) -> bool {
|| has_deepseek_r_series_marker(&lower)
}
fn should_replay_reasoning_content(model: &str, effort: Option<&str>) -> bool {
if effort
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"off" | "disabled" | "none" | "false"
)
})
.unwrap_or(false)
{
return false;
}
requires_reasoning_content(model)
}
/// Translate the TUI's effort-tier string into DeepSeek's request fields.
///
/// The config surface accepts `off | low | medium | high | max`. DeepSeek
@@ -2424,6 +2477,101 @@ mod tests {
assert!(assistant.get("tool_calls").is_some());
}
#[test]
fn chat_messages_drop_v4_tool_round_missing_reasoning() {
let messages = vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "call-without-reasoning".to_string(),
name: "read_file".to_string(),
input: json!({"path": "Cargo.toml"}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "call-without-reasoning".to_string(),
content: "workspace manifest".to_string(),
is_error: None,
content_blocks: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "continue".to_string(),
cache_control: None,
}],
},
];
let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
assert!(
!out.iter()
.any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")),
"malformed assistant tool round should be removed"
);
assert!(
!out.iter()
.any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
"tool result tied to missing reasoning should be removed"
);
}
#[test]
fn chat_messages_allow_tool_round_without_reasoning_when_thinking_disabled() {
let request = MessageRequest {
model: "deepseek-v4-pro".to_string(),
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "call-no-thinking".to_string(),
name: "read_file".to_string(),
input: json!({"path": "Cargo.toml"}),
caller: None,
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "call-no-thinking".to_string(),
content: "workspace manifest".to_string(),
is_error: None,
content_blocks: None,
}],
},
],
max_tokens: 1024,
system: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: Some("off".to_string()),
stream: None,
temperature: None,
top_p: None,
};
let out = build_chat_messages_for_request(&request);
assert!(
out.iter().any(
|value| value.get("role").and_then(Value::as_str) == Some("assistant")
&& value.get("tool_calls").is_some()
),
"tool calls remain valid when thinking mode is disabled"
);
assert!(
out.iter()
.any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
"matching tool result should remain"
);
}
#[test]
fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() {
let mut body = json!({});
@@ -2622,12 +2770,17 @@ mod tests {
let messages = vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "list_dir".to_string(),
input: json!({}),
caller: None,
}],
content: vec![
ContentBlock::Thinking {
thinking: "Need to inspect the directory".to_string(),
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "list_dir".to_string(),
input: json!({}),
caller: None,
},
],
},
Message {
role: "user".to_string(),
@@ -2657,12 +2810,17 @@ mod tests {
let messages = vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "web.run".to_string(),
input: json!({}),
caller: None,
}],
content: vec![
ContentBlock::Thinking {
thinking: "Need to search".to_string(),
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "web.run".to_string(),
input: json!({}),
caller: None,
},
],
},
Message {
role: "user".to_string(),
@@ -2741,12 +2899,17 @@ mod tests {
let messages = vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "tool-ok".to_string(),
name: "list_dir".to_string(),
input: json!({}),
caller: None,
}],
content: vec![
ContentBlock::Thinking {
thinking: "Need to list files".to_string(),
},
ContentBlock::ToolUse {
id: "tool-ok".to_string(),
name: "list_dir".to_string(),
input: json!({}),
caller: None,
},
],
},
Message {
role: "user".to_string(),
+46 -14
View File
@@ -1542,6 +1542,7 @@ impl Engine {
};
self.session.rebuild_working_set();
self.rehydrate_latest_canonical_state();
self.emit_session_updated().await;
let _ = self
.tx_event
.send(Event::status("Session context synced".to_string()))
@@ -1557,6 +1558,23 @@ impl Engine {
}
}
async fn emit_session_updated(&self) {
let _ = self
.tx_event
.send(Event::SessionUpdated {
messages: self.session.messages.clone(),
system_prompt: self.session.system_prompt.clone(),
model: self.session.model.clone(),
workspace: self.session.workspace.clone(),
})
.await;
}
async fn add_session_message(&mut self, message: Message) {
self.session.add_message(message);
self.emit_session_updated().await;
}
/// Handle a send message operation
#[allow(clippy::too_many_arguments)]
async fn handle_send_message(
@@ -1636,6 +1654,7 @@ impl Engine {
// Update system prompt to match current mode and include persisted compaction context.
self.refresh_system_prompt(mode);
self.emit_session_updated().await;
// Build tool registry and tool list for the current mode
let todo_list = self.config.todos.clone();
@@ -1828,6 +1847,7 @@ impl Engine {
let messages_after = result.messages.len();
self.session.messages = result.messages;
self.merge_compaction_summary(result.summary_prompt);
self.emit_session_updated().await;
let removed = messages_before.saturating_sub(messages_after);
let message = if result.retries_used > 0 {
format!(
@@ -1976,6 +1996,7 @@ impl Engine {
self.merge_compaction_summary(summary_prompt);
let trimmed = self.trim_oldest_messages_to_budget(target_budget);
self.emit_session_updated().await;
let after_tokens = self.estimated_input_tokens();
let after_count = self.session.messages.len();
let recovered = after_tokens <= target_budget
@@ -2361,13 +2382,14 @@ impl Engine {
self.session
.working_set
.observe_user_message(&steer, &self.session.workspace);
self.session.add_message(Message {
self.add_session_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: steer.clone(),
cache_control: None,
}],
});
})
.await;
let _ = self
.tx_event
.send(Event::status(format!(
@@ -2433,6 +2455,7 @@ impl Engine {
let auto_messages_after = result.messages.len();
self.session.messages = result.messages;
self.merge_compaction_summary(result.summary_prompt);
self.emit_session_updated().await;
let removed = auto_messages_before.saturating_sub(auto_messages_after);
let status = if result.retries_used > 0 {
format!(
@@ -2947,10 +2970,11 @@ impl Engine {
// Add assistant message to session
if has_sendable_assistant_content {
self.session.add_message(Message {
self.add_session_message(Message {
role: "assistant".to_string(),
content: content_blocks,
});
})
.await;
}
// If no tool uses, we're done
@@ -2960,13 +2984,14 @@ impl Engine {
self.session
.working_set
.observe_user_message(&steer, &self.session.workspace);
self.session.add_message(Message {
self.add_session_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: steer,
cache_control: None,
}],
});
})
.await;
}
turn.next_step();
continue;
@@ -3446,7 +3471,7 @@ impl Engine {
Some(&output_for_context),
&self.session.workspace,
);
self.session.add_message(Message {
self.add_session_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: outcome.id,
@@ -3454,7 +3479,8 @@ impl Engine {
is_error: None,
content_blocks: None,
}],
});
})
.await;
}
Err(e) => {
emit_tool_audit(json!({
@@ -3473,7 +3499,7 @@ impl Engine {
Some(&error),
&self.session.workspace,
);
self.session.add_message(Message {
self.add_session_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: outcome.id,
@@ -3481,7 +3507,8 @@ impl Engine {
is_error: Some(true),
content_blocks: None,
}],
});
})
.await;
}
}
@@ -3514,13 +3541,14 @@ impl Engine {
self.session
.working_set
.observe_user_message(&steer, &self.session.workspace);
self.session.add_message(Message {
self.add_session_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: steer,
cache_control: None,
}],
});
})
.await;
}
}
@@ -3903,6 +3931,7 @@ impl Engine {
None,
)));
self.refresh_system_prompt(mode);
self.emit_session_updated().await;
let after_tokens = self.estimated_input_tokens();
self.emit_capacity_intervention(
@@ -4005,7 +4034,7 @@ impl Engine {
"[verification replay] tool={} pass={} details={}",
candidate.name, pass, diff_summary
);
self.session.add_message(Message {
self.add_session_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: candidate.id.clone(),
@@ -4013,7 +4042,8 @@ impl Engine {
is_error: None,
content_blocks: None,
}],
});
})
.await;
if !pass {
self.capacity_controller
@@ -4053,6 +4083,7 @@ impl Engine {
Some(&verification_note),
)));
self.refresh_system_prompt(mode);
self.emit_session_updated().await;
let after_tokens = self.estimated_input_tokens();
self.emit_capacity_intervention(
@@ -4135,6 +4166,7 @@ impl Engine {
Some("Replan now from canonical state. Keep steps minimal and verifiable."),
)));
self.refresh_system_prompt(mode);
self.emit_session_updated().await;
let _ = self
.tx_event
+31
View File
@@ -210,6 +210,37 @@ fn agent_mode_can_build_auto_approved_tool_context() {
assert!(engine.build_tool_context(AppMode::Yolo, false).auto_approve);
}
#[tokio::test]
async fn session_update_preserves_reasoning_tool_only_turn() {
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
let assistant = Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Thinking {
thinking: "Need a tool before answering.".to_string(),
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "Cargo.toml"}),
caller: None,
},
],
};
engine.add_session_message(assistant.clone()).await;
let event = {
let mut rx = handle.rx_event.write().await;
rx.recv().await.expect("session update event")
};
let Event::SessionUpdated { messages, .. } = event else {
panic!("expected session update event");
};
assert_eq!(messages, vec![assistant]);
}
#[test]
fn detects_context_length_errors_from_provider_payloads() {
let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
+17 -1
View File
@@ -3,9 +3,11 @@
//! These events flow from the engine to the TUI via a channel,
//! enabling non-blocking, real-time updates.
use std::path::PathBuf;
use serde_json::Value;
use crate::models::Usage;
use crate::models::{Message, SystemPrompt, Usage};
use crate::tools::spec::{ToolError, ToolResult};
use crate::tools::subagent::SubAgentResult;
use crate::tools::user_input::UserInputRequest;
@@ -199,6 +201,20 @@ pub enum Event {
request: UserInputRequest,
},
/// Authoritative API conversation state from the engine session.
///
/// The UI receives granular display events, but those are not always a
/// lossless representation of the API transcript. DeepSeek can emit
/// reasoning directly followed by tool calls without a visible assistant
/// text block, and that assistant message still has to be persisted for
/// later `reasoning_content` replay.
SessionUpdated {
messages: Vec<Message>,
system_prompt: Option<SystemPrompt>,
model: String,
workspace: PathBuf,
},
/// Request user decision after sandbox denial
#[allow(dead_code)]
ElevationRequired {
+183 -11
View File
@@ -8,6 +8,7 @@ use super::spec::{
optional_u64, required_str,
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -473,7 +474,7 @@ impl ToolSpec for WebRunTool {
})
.unwrap_or_default();
let (entries, warning) =
let (entries, source, warning) =
run_search(&query, max_results, timeout_ms, &domains).await?;
let mut warnings = Vec::new();
if recency > 0 {
@@ -493,7 +494,7 @@ impl ToolSpec for WebRunTool {
results.push(SearchResult {
ref_id,
query,
source: "duckduckgo".to_string(),
source,
count: entries.len(),
results: entries,
warning: if warnings.is_empty() {
@@ -707,7 +708,7 @@ async fn run_search(
max_results: usize,
timeout_ms: u64,
domains: &[String],
) -> Result<(Vec<SearchEntry>, Option<String>), ToolError> {
) -> Result<(Vec<SearchEntry>, String, Option<String>), ToolError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
@@ -741,19 +742,85 @@ async fn run_search(
}
let mut results = parse_duckduckgo_results(&body, max_results);
let warning = if !domains.is_empty() {
let mut source = "duckduckgo".to_string();
let mut warnings = Vec::new();
if results.is_empty() {
let duckduckgo_blocked = is_duckduckgo_challenge(&body);
match run_bing_search(&client, query, max_results).await {
Ok(fallback_results) if !fallback_results.is_empty() => {
results = fallback_results;
source = "bing".to_string();
warnings.push(if duckduckgo_blocked {
"DuckDuckGo returned a bot challenge; used Bing fallback".to_string()
} else {
"DuckDuckGo returned no parseable results; used Bing fallback".to_string()
});
}
Ok(_) if duckduckgo_blocked => {
return Err(ToolError::execution_failed(
"DuckDuckGo returned a bot challenge and Bing fallback returned no results",
));
}
Err(err) if duckduckgo_blocked => {
return Err(ToolError::execution_failed(format!(
"DuckDuckGo returned a bot challenge and Bing fallback failed: {err}"
)));
}
Ok(_) | Err(_) => {}
}
}
if !domains.is_empty() {
let before = results.len();
results.retain(|entry| domain_matches(&entry.url, domains));
if before != results.len() {
Some("Filtered search results by domain list".to_string())
} else {
None
warnings.push("Filtered search results by domain list".to_string());
}
} else {
None
};
}
Ok((results, warning))
Ok((
results,
source,
if warnings.is_empty() {
None
} else {
Some(warnings.join("; "))
},
))
}
async fn run_bing_search(
client: &reqwest::Client,
query: &str,
max_results: usize,
) -> Result<Vec<SearchEntry>, ToolError> {
let encoded = url_encode(query);
let url = format!("https://www.bing.com/search?q={encoded}");
let resp = client
.get(&url)
.header(
"Accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.header("Accept-Language", "en-US,en;q=0.9")
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Bing fallback request failed: {e}")))?;
let status = resp.status();
let body = resp.text().await.map_err(|e| {
ToolError::execution_failed(format!("Failed to read Bing fallback response: {e}"))
})?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Bing fallback failed: HTTP {}",
status.as_u16()
)));
}
Ok(parse_bing_results(&body, max_results))
}
fn domain_matches(url: &str, domains: &[String]) -> bool {
@@ -1189,6 +1256,9 @@ static STYLE_RE: OnceLock<Regex> = OnceLock::new();
static TITLE_RE: OnceLock<Regex> = OnceLock::new();
static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
static SEARCH_TITLE_RE: OnceLock<Regex> = OnceLock::new();
static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
fn get_anchor_re() -> &'static Regex {
ANCHOR_RE.get_or_init(|| {
@@ -1236,6 +1306,27 @@ fn get_search_snippet_re() -> &'static Regex {
})
}
fn get_bing_result_re() -> &'static Regex {
BING_RESULT_RE.get_or_init(|| {
Regex::new(r#"(?is)<li[^>]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)</li>"#)
.expect("bing result regex pattern is valid")
})
}
fn get_bing_title_re() -> &'static Regex {
BING_TITLE_RE.get_or_init(|| {
Regex::new(r#"(?is)<h2[^>]*>.*?<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
.expect("bing title regex pattern is valid")
})
}
fn get_bing_snippet_re() -> &'static Regex {
BING_SNIPPET_RE.get_or_init(|| {
Regex::new(r#"(?is)<div[^>]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?<p[^>]*>(.*?)</p>"#)
.expect("bing snippet regex pattern is valid")
})
}
fn parse_html(html: &str, base_url: &str) -> (Vec<String>, Vec<WebLink>, Option<String>) {
let title = extract_title(html);
let without_scripts = get_script_re().replace_all(html, "").to_string();
@@ -1398,6 +1489,44 @@ fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<SearchEntry>
results
}
fn is_duckduckgo_challenge(html: &str) -> bool {
html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too")
}
fn parse_bing_results(html: &str, max_results: usize) -> Vec<SearchEntry> {
let mut results = Vec::new();
for cap in get_bing_result_re().captures_iter(html) {
if results.len() >= max_results {
break;
}
let Some(block) = cap.get(1).map(|m| m.as_str()) else {
continue;
};
let Some(title_cap) = get_bing_title_re().captures(block) else {
continue;
};
let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or("");
let title_raw = title_cap.get(2).map(|m| m.as_str()).unwrap_or("");
let title = normalize_whitespace(&decode_html_entities(&strip_tags(title_raw)));
if title.is_empty() {
continue;
}
let snippet = get_bing_snippet_re()
.captures(block)
.and_then(|snippet_cap| snippet_cap.get(1))
.map(|m| normalize_whitespace(&decode_html_entities(&strip_tags(m.as_str()))))
.filter(|s| !s.is_empty());
results.push(SearchEntry {
title,
url: normalize_bing_url(href),
snippet,
});
}
results
}
fn normalize_search_url(href: &str) -> String {
if let Some(uddg) = extract_query_param(href, "uddg") {
let decoded = percent_decode(&uddg);
@@ -1414,6 +1543,30 @@ fn normalize_search_url(href: &str) -> String {
href.to_string()
}
fn normalize_bing_url(href: &str) -> String {
if let Some(encoded) = extract_query_param(href, "u") {
let decoded = percent_decode(&encoded);
let token = decoded.strip_prefix("a1").unwrap_or(&decoded);
let mut padded = token.replace('-', "+").replace('_', "/");
while !padded.len().is_multiple_of(4) {
padded.push('=');
}
if let Ok(bytes) = general_purpose::STANDARD.decode(padded)
&& let Ok(url) = String::from_utf8(bytes)
&& looks_like_url(&url)
{
return url;
}
}
if href.starts_with("//") {
return format!("https:{href}");
}
if href.starts_with('/') {
return format!("https://www.bing.com{href}");
}
href.to_string()
}
fn extract_query_param(url: &str, key: &str) -> Option<String> {
let query_start = url.find('?')?;
let query = &url[query_start + 1..];
@@ -1510,6 +1663,25 @@ mod tests {
);
}
#[test]
fn parses_bing_results_and_decodes_redirect_url() {
let html = r#"
<ol>
<li class="b_algo">
<h2><a href="https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS9wYXRoP3E9MQ">Example &amp; Result</a></h2>
<div class="b_caption"><p>A <strong>useful</strong> snippet.</p></div>
</li>
</ol>
"#;
let results = parse_bing_results(html, 5);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Example & Result");
assert_eq!(results[0].url, "https://example.com/path?q=1");
assert_eq!(results[0].snippet.as_deref(), Some("A useful snippet."));
}
#[test]
fn scoped_ref_prefix_is_session_specific() {
reset_web_run_state();
+151 -2
View File
@@ -8,6 +8,7 @@ use super::spec::{
optional_u64, required_str,
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose};
use regex::Regex;
use serde::Serialize;
use serde_json::{Value, json};
@@ -18,6 +19,9 @@ use std::time::Duration;
static TITLE_RE: OnceLock<Regex> = OnceLock::new();
static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
static TAG_RE: OnceLock<Regex> = OnceLock::new();
static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
fn get_title_re() -> &'static Regex {
TITLE_RE.get_or_init(|| {
@@ -39,6 +43,27 @@ fn get_tag_re() -> &'static Regex {
TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex pattern is valid"))
}
fn get_bing_result_re() -> &'static Regex {
BING_RESULT_RE.get_or_init(|| {
Regex::new(r#"(?is)<li[^>]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)</li>"#)
.expect("bing result regex pattern is valid")
})
}
fn get_bing_title_re() -> &'static Regex {
BING_TITLE_RE.get_or_init(|| {
Regex::new(r#"(?is)<h2[^>]*>.*?<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
.expect("bing title regex pattern is valid")
})
}
fn get_bing_snippet_re() -> &'static Regex {
BING_SNIPPET_RE.get_or_init(|| {
Regex::new(r#"(?is)<div[^>]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?<p[^>]*>(.*?)</p>"#)
.expect("bing snippet regex pattern is valid")
})
}
const DEFAULT_MAX_RESULTS: usize = 5;
const MAX_RESULTS: usize = 10;
const DEFAULT_TIMEOUT_MS: u64 = 15_000;
@@ -149,16 +174,45 @@ impl ToolSpec for WebSearchTool {
)));
}
let results = parse_duckduckgo_results(&body, max_results);
let mut results = parse_duckduckgo_results(&body, max_results);
let mut source = "duckduckgo".to_string();
let mut message_suffix = None;
if results.is_empty() {
let duckduckgo_blocked = is_duckduckgo_challenge(&body);
match run_bing_search(&client, &query, max_results).await {
Ok(fallback_results) if !fallback_results.is_empty() => {
results = fallback_results;
source = "bing".to_string();
message_suffix = Some(if duckduckgo_blocked {
"DuckDuckGo returned a bot challenge; used Bing fallback"
} else {
"DuckDuckGo returned no parseable results; used Bing fallback"
});
}
Ok(_) if duckduckgo_blocked => {
return Err(ToolError::execution_failed(
"DuckDuckGo returned a bot challenge and Bing fallback returned no results",
));
}
Err(err) if duckduckgo_blocked => {
return Err(ToolError::execution_failed(format!(
"DuckDuckGo returned a bot challenge and Bing fallback failed: {err}"
)));
}
Ok(_) | Err(_) => {}
}
}
let message = if results.is_empty() {
"No results found".to_string()
} else if let Some(suffix) = message_suffix {
format!("Found {} result(s). {suffix}", results.len())
} else {
format!("Found {} result(s)", results.len())
};
let response = WebSearchResponse {
query,
source: "duckduckgo".to_string(),
source,
count: results.len(),
message,
results,
@@ -168,6 +222,39 @@ impl ToolSpec for WebSearchTool {
}
}
async fn run_bing_search(
client: &reqwest::Client,
query: &str,
max_results: usize,
) -> Result<Vec<WebSearchEntry>, ToolError> {
let encoded = url_encode(query);
let url = format!("https://www.bing.com/search?q={encoded}");
let resp = client
.get(&url)
.header(
"Accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.header("Accept-Language", "en-US,en;q=0.9")
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Bing fallback request failed: {e}")))?;
let status = resp.status();
let body = resp.text().await.map_err(|e| {
ToolError::execution_failed(format!("Failed to read Bing fallback response: {e}"))
})?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Bing fallback failed: HTTP {}",
status.as_u16()
)));
}
Ok(parse_bing_results(&body, max_results))
}
fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<WebSearchEntry> {
let title_re = get_title_re();
let snippet_re = get_snippet_re();
@@ -204,6 +291,44 @@ fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<WebSearchEntr
results
}
fn is_duckduckgo_challenge(html: &str) -> bool {
html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too")
}
fn parse_bing_results(html: &str, max_results: usize) -> Vec<WebSearchEntry> {
let mut results = Vec::new();
for cap in get_bing_result_re().captures_iter(html) {
if results.len() >= max_results {
break;
}
let Some(block) = cap.get(1).map(|m| m.as_str()) else {
continue;
};
let Some(title_cap) = get_bing_title_re().captures(block) else {
continue;
};
let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or("");
let title_raw = title_cap.get(2).map(|m| m.as_str()).unwrap_or("");
let title = normalize_text(title_raw);
if title.is_empty() {
continue;
}
let snippet = get_bing_snippet_re()
.captures(block)
.and_then(|snippet_cap| snippet_cap.get(1))
.map(|m| normalize_text(m.as_str()))
.filter(|s| !s.is_empty());
results.push(WebSearchEntry {
title,
url: normalize_bing_url(href),
snippet,
});
}
results
}
fn normalize_url(href: &str) -> String {
if let Some(uddg) = extract_query_param(href, "uddg") {
let decoded = percent_decode(&uddg);
@@ -220,6 +345,30 @@ fn normalize_url(href: &str) -> String {
href.to_string()
}
fn normalize_bing_url(href: &str) -> String {
if let Some(encoded) = extract_query_param(href, "u") {
let decoded = percent_decode(&encoded);
let token = decoded.strip_prefix("a1").unwrap_or(&decoded);
let mut padded = token.replace('-', "+").replace('_', "/");
while !padded.len().is_multiple_of(4) {
padded.push('=');
}
if let Ok(bytes) = general_purpose::STANDARD.decode(padded)
&& let Ok(url) = String::from_utf8(bytes)
&& (url.starts_with("http://") || url.starts_with("https://"))
{
return url;
}
}
if href.starts_with("//") {
return format!("https:{href}");
}
if href.starts_with('/') {
return format!("https://www.bing.com{href}");
}
href.to_string()
}
fn normalize_text(text: &str) -> String {
let stripped = strip_html_tags(text);
let decoded = decode_html_entities(&stripped);
+15
View File
@@ -575,6 +575,21 @@ async fn run_event_loop(
EngineEvent::Status { message } => {
app.status_message = Some(message);
}
EngineEvent::SessionUpdated {
messages,
system_prompt,
model,
workspace,
} => {
app.api_messages = messages;
app.system_prompt = system_prompt;
app.model = model;
app.update_model_compaction_budget();
app.workspace = workspace;
if app.is_loading || app.is_compacting {
persist_checkpoint(app);
}
}
EngineEvent::CompactionStarted { message, .. } => {
app.is_compacting = true;
app.status_message = Some(message);
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "deepseek-tui",
"version": "0.4.1",
"deepseekBinaryVersion": "0.4.1",
"version": "0.4.2",
"deepseekBinaryVersion": "0.4.2",
"description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
"author": "Hmbown",
"license": "MIT",