fix: preserve DeepSeek V4 reasoning tool turns
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}}"#;
|
||||
|
||||
@@ -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
@@ -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 & 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user