Release v0.3.14

- Add image_query support to web.run (DuckDuckGo image search)
- Encode tool-call function names for Chat Completions history rebuild
- Allow safe MCP meta tools in multi_tool_use.parallel
- Update prompts for stronger citation placement and quote-limit guidance
This commit is contained in:
Hunter Bown
2026-02-05 09:28:39 -06:00
parent 4ded9e2a4f
commit 702f9c353c
10 changed files with 409 additions and 40 deletions
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.3.14] - 2026-02-05
### Added
- `web.run` now supports `image_query` (DuckDuckGo image search)
- `multi_tool_use.parallel` now supports safe MCP meta tools (`list_mcp_resources`, `mcp_read_resource`, etc.)
### Fixed
- Encode tool-call function names when rebuilding Chat Completions history (keeps dotted tool names API-safe)
### Changed
- Prompts: stronger `web.run` citation placement and quote-limit guidance
## [0.3.13] - 2026-02-04
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deepseek-tui"
version = "0.3.13"
version = "0.3.14"
edition = "2024"
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
license = "MIT"
+7 -7
View File
@@ -40,10 +40,10 @@ The Codex harness baseline (as of 2026-02-03) includes:
| Patch apply | apply_patch | apply_patch | Parity | - |
| Code search | rg via shell | grep_files, file_search, exec_shell | Parity | - |
| Shell exec | exec_command + write_stdin | exec_shell | Parity | PTY + stdin streaming via exec_shell_wait/exec_shell_interact |
| Web search/browse | web.run (search/open/click/find/screenshot) | web.run + web_search | Partial | web.run implemented; citations via prompts only (no word-limit enforcement) |
| Image search | image_query | missing | Missing | - |
| Web search/browse | web.run (search/open/click/find/screenshot) | web.run + web_search | Partial | web.run implemented; citation placement + quote limits enforced via prompts (no word-limit enforcement) |
| Image search | image_query | web.run image_query | Parity | DuckDuckGo image search via web.run.image_query |
| Structured data | weather/finance/sports/time/calculator | weather/finance/sports/time/calculator | Partial | Uses public data sources; coverage may vary by league/market |
| Multi-tool parallel | multi_tool_use.parallel | multi_tool_use.parallel | Partial | Read-only tools only; no MCP tools |
| Multi-tool parallel | multi_tool_use.parallel | multi_tool_use.parallel | Partial | Read-only tools plus safe MCP meta tools (list/read/get prompt) |
| User input tool | request_user_input | request_user_input | Parity | - |
| MCP resources | list/read resources + get prompt | list_mcp_resources, list_mcp_resource_templates, mcp_read_resource, mcp_get_prompt | Parity | - |
| Sub-agents | spawn/send_input/wait/close | agent_spawn/send_input/wait/agent_cancel/agent_list/agent_swarm | Partial | send_input/wait added; close maps to agent_cancel |
@@ -75,10 +75,10 @@ Citation format (current): `[cite:ref_id]` using the `ref_id` returned by `web.r
## Gap Backlog (Prioritized)
1. Add image_query tool (image search parity)
2. Enforce web.run citation placement/quote limits in prompts or tooling
3. Expand structured data coverage for edge leagues/markets
4. Allow multi_tool_use.parallel to include MCP tools (where safe)
1. Add image_query support (image search parity)
2. Enforce web.run citation placement/quote limits in prompts or tooling
3. Expand structured data coverage for edge leagues/markets
4. Allow multi_tool_use.parallel to include MCP tools (where safe)
## Parity Gates (Acceptance)
+2 -2
View File
@@ -18,7 +18,7 @@ Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.
- **Task management**: Todo lists, implementation plans, persistent notes
- **Sub-agent system**: Spawn, coordinate, and cancel background agents (including swarms)
- **User input prompts**: Ask structured, multiple-choice questions during tool flows
- **Web browsing**: `web.run` search/open/click/find/screenshot with citation-ready sources
- **Web browsing**: `web.run` search/open/click/find/screenshot/image_query with citation-ready sources
- **Structured data tools**: weather, finance, sports, time, calculator
- **Multimodel support** DeepSeekReasoner, DeepSeekChat, and other DeepSeek models
- **Contextaware** loads projectspecific instructions from `AGENTS.md`
@@ -123,7 +123,7 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori
- **`edit_file`** Search and replace text in files
- **`apply_patch`** Apply unified diff patches with fuzzy matching
- **`grep_files`** Search files by regex pattern with context lines
- **`web.run`** Browse the web (search/open/click/find/screenshot) with ref_ids for citations
- **`web.run`** Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- **`web_search`** Quick web search (fallback when citations are not needed)
- **`request_user_input`** Ask the user short multiple-choice questions
- **`multi_tool_use.parallel`** Execute multiple read-only tools in parallel
+40 -1
View File
@@ -515,7 +515,7 @@ fn build_chat_messages(
"id": id,
"type": "function",
"function": {
"name": name,
"name": to_api_tool_name(name),
"arguments": args,
}
}));
@@ -1040,6 +1040,45 @@ mod tests {
assert!(assistant.get("tool_calls").is_some());
}
#[test]
fn chat_messages_encode_tool_call_names() {
let messages = vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "web.run".to_string(),
input: json!({}),
}],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: "ok".to_string(),
}],
},
];
let out = build_chat_messages(None, &messages, "deepseek-chat");
let assistant = out
.iter()
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
.expect("assistant message");
let tool_calls = assistant
.get("tool_calls")
.and_then(Value::as_array)
.expect("tool_calls array");
let function_name = tool_calls
.first()
.and_then(|call| call.get("function"))
.and_then(|func| func.get("name"))
.and_then(Value::as_str)
.expect("tool call function name");
assert_eq!(function_name, to_api_tool_name("web.run"));
}
#[test]
fn chat_messages_strips_orphaned_tool_calls_after_compaction() {
// Simulates post-compaction state: assistant has tool_calls but the
+46 -24
View File
@@ -473,6 +473,17 @@ fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool {
})
}
fn mcp_tool_is_parallel_safe(name: &str) -> bool {
matches!(
name,
"list_mcp_resources"
| "list_mcp_resource_templates"
| "mcp_read_resource"
| "read_mcp_resource"
| "mcp_get_prompt"
)
}
fn format_tool_error(err: &ToolError, tool_name: &str) -> String {
match err {
ToolError::InvalidInput { message } => {
@@ -945,6 +956,11 @@ impl Engine {
tool_exec_lock: Arc<RwLock<()>>,
) -> Result<ToolResult, ToolError> {
let calls = parse_parallel_tool_calls(&input)?;
let mcp_pool = if calls.iter().any(|(tool, _)| McpPool::is_mcp_tool(tool)) {
Some(self.ensure_mcp_pool().await?)
} else {
None
};
let Some(registry) = tool_registry else {
return Err(ToolError::not_available(
"tool registry unavailable for multi_tool_use.parallel",
@@ -959,34 +975,40 @@ impl Engine {
));
}
if McpPool::is_mcp_tool(&tool_name) {
return Err(ToolError::invalid_input(
"multi_tool_use.parallel does not support MCP tools",
));
}
let Some(spec) = registry.get(&tool_name) else {
return Err(ToolError::not_available(format!(
"tool '{tool_name}' is not registered"
)));
};
if !spec.is_read_only() {
return Err(ToolError::invalid_input(format!(
"Tool '{tool_name}' is not read-only and cannot run in parallel"
)));
}
if spec.approval_requirement() != ApprovalRequirement::Auto {
return Err(ToolError::invalid_input(format!(
"Tool '{tool_name}' requires approval and cannot run in parallel"
)));
}
if !spec.supports_parallel() {
return Err(ToolError::invalid_input(format!(
"Tool '{tool_name}' does not support parallel execution"
)));
if !mcp_tool_is_parallel_safe(&tool_name) {
return Err(ToolError::invalid_input(format!(
"Tool '{tool_name}' is an MCP tool and cannot run in parallel. \
Allowed MCP tools: list_mcp_resources, list_mcp_resource_templates, \
mcp_read_resource, read_mcp_resource, mcp_get_prompt."
)));
}
} else {
let Some(spec) = registry.get(&tool_name) else {
return Err(ToolError::not_available(format!(
"tool '{tool_name}' is not registered"
)));
};
if !spec.is_read_only() {
return Err(ToolError::invalid_input(format!(
"Tool '{tool_name}' is not read-only and cannot run in parallel"
)));
}
if spec.approval_requirement() != ApprovalRequirement::Auto {
return Err(ToolError::invalid_input(format!(
"Tool '{tool_name}' requires approval and cannot run in parallel"
)));
}
if !spec.supports_parallel() {
return Err(ToolError::invalid_input(format!(
"Tool '{tool_name}' does not support parallel execution"
)));
}
}
let registry_ref = registry;
let lock = tool_exec_lock.clone();
let tx_event = self.tx_event.clone();
let mcp_pool = mcp_pool.clone();
tasks.push(async move {
let result = Engine::execute_tool_with_lock(
lock,
@@ -996,7 +1018,7 @@ impl Engine {
tool_name.clone(),
tool_input.clone(),
Some(registry_ref),
None,
mcp_pool,
None,
)
.await;
+8 -1
View File
@@ -17,6 +17,13 @@ Tool selection guidance:
- Use exec_shell for objective verification: build, test, format, lint, and targeted checks.
- Use web.run when local context is insufficient or time-sensitive, and cite sources as [cite:ref_id].
Web browsing and citations:
- Use web.run when info might have changed or you are unsure.
- Cite non-trivial factual claims using [cite:ref_id] (the ref_id returned by web.run).
- Place citations at the end of the sentence/paragraph they support; do not dump all citations at the end.
- Quote limits: do not quote more than 25 words verbatim from a single non-lyrical source (10 words for lyrics).
- Avoid reproducing full articles or large excerpts; prefer short quotes + paraphrase.
Testing and stop conditions:
- After any change, run the most relevant tests/checks before declaring success.
- Start narrow (targeted tests) and expand to broader checks when appropriate.
@@ -35,7 +42,7 @@ FILE OPERATIONS:
- edit_file: Search and replace text in a file
- apply_patch: Apply a unified diff patch to a file
- grep_files: Search files by regex
- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
+1 -1
View File
@@ -11,7 +11,7 @@ Available tools in this mode:
- edit_file: Search and replace text in a file (ask first)
- apply_patch: Apply a unified diff patch (ask first)
- grep_files: Search files by regex
- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
+1 -1
View File
@@ -18,7 +18,7 @@ EXPLORATION:
- list_dir: Browse directories in the workspace
- read_file: Read file contents to understand context
- grep_files: Search files by regex
- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
+291 -2
View File
@@ -9,7 +9,7 @@ use super::spec::{
};
use async_trait::async_trait;
use regex::Regex;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
@@ -151,11 +151,40 @@ struct ScreenshotResult {
content: String,
}
#[derive(Debug, Clone, Serialize)]
struct ImageResultEntry {
image: String,
#[serde(skip_serializing_if = "Option::is_none")]
thumbnail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
height: Option<u32>,
}
#[derive(Debug, Clone, Serialize)]
struct ImageQueryResult {
query: String,
source: String,
count: usize,
results: Vec<ImageResultEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
warning: Option<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
struct WebRunOutput {
#[serde(skip_serializing_if = "Option::is_none")]
search_query: Option<Vec<SearchResult>>,
#[serde(skip_serializing_if = "Option::is_none")]
image_query: Option<Vec<ImageQueryResult>>,
#[serde(skip_serializing_if = "Option::is_none")]
open: Option<Vec<PageViewResult>>,
#[serde(skip_serializing_if = "Option::is_none")]
click: Option<Vec<PageViewResult>>,
@@ -176,7 +205,7 @@ impl ToolSpec for WebRunTool {
}
fn description(&self) -> &'static str {
"Browse the web (search/open/click/find/screenshot) and return structured results with ref_ids for citations."
"Browse the web (search/open/click/find/screenshot/image_query) and return structured results with ref_ids for citations."
}
fn input_schema(&self) -> Value {
@@ -190,6 +219,22 @@ impl ToolSpec for WebRunTool {
"properties": {
"q": { "type": "string" },
"recency": { "type": "integer" },
"max_results": { "type": "integer" },
"timeout_ms": { "type": "integer" },
"domains": { "type": "array", "items": { "type": "string" } }
},
"required": ["q"]
}
},
"image_query": {
"type": "array",
"items": {
"type": "object",
"properties": {
"q": { "type": "string" },
"recency": { "type": "integer" },
"max_results": { "type": "integer" },
"timeout_ms": { "type": "integer" },
"domains": { "type": "array", "items": { "type": "string" } }
},
"required": ["q"]
@@ -332,6 +377,63 @@ impl ToolSpec for WebRunTool {
}
}
if let Some(images) = input.get("image_query").and_then(|v| v.as_array()) {
let mut results = Vec::new();
for image in images {
let query = required_str(image, "q")?.trim().to_string();
if query.is_empty() {
continue;
}
let recency = optional_u64(image, "recency", 0);
let max_results = usize::try_from(optional_u64(
image,
"max_results",
response_length.max_results() as u64,
))
.unwrap_or(response_length.max_results())
.clamp(1, MAX_RESULTS);
let timeout_ms = optional_u64(image, "timeout_ms", DEFAULT_TIMEOUT_MS).min(60_000);
let domains = image
.get("domains")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let (entries, warning) =
run_image_search(&query, max_results, timeout_ms, &domains).await?;
let mut warnings = Vec::new();
if recency > 0 {
warnings.push(format!(
"Recency filter not enforced (requested last {recency} days)"
));
}
if let Some(w) = warning {
warnings.push(w);
}
results.push(ImageQueryResult {
query,
source: "duckduckgo_images".to_string(),
count: entries.len(),
results: entries,
warning: if warnings.is_empty() {
None
} else {
Some(warnings.join("; "))
},
});
}
if !results.is_empty() {
output.image_query = Some(results);
}
}
if let Some(opens) = input.get("open").and_then(|v| v.as_array()) {
let mut views = Vec::new();
for open in opens {
@@ -520,6 +622,172 @@ fn domain_matches(url: &str, domains: &[String]) -> bool {
})
}
#[derive(Debug, Clone, Deserialize)]
struct DuckDuckGoImageResponse {
#[serde(default)]
results: Vec<DuckDuckGoImageResult>,
}
#[derive(Debug, Clone, Deserialize)]
struct DuckDuckGoImageResult {
image: String,
#[serde(default)]
thumbnail: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
source: Option<String>,
#[serde(default)]
width: Option<u32>,
#[serde(default)]
height: Option<u32>,
}
fn extract_duckduckgo_vqd(html: &str) -> Option<String> {
let html = html.trim();
if html.is_empty() {
return None;
}
for (prefix, suffix) in [("vqd='", "'"), ("vqd=\"", "\"")] {
if let Some(start) = html.find(prefix) {
let rest = &html[start + prefix.len()..];
if let Some(end) = rest.find(suffix) {
let token = rest[..end].trim();
if !token.is_empty() {
return Some(token.to_string());
}
}
}
}
// Fallback: look for `vqd=` and accept a conservative token charset.
if let Some(start) = html.find("vqd=") {
let rest = &html[start + 4..];
let mut token = String::new();
for ch in rest.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
token.push(ch);
} else {
break;
}
}
if !token.is_empty() {
return Some(token);
}
}
None
}
async fn run_image_search(
query: &str,
max_results: usize,
timeout_ms: u64,
domains: &[String],
) -> Result<(Vec<ImageResultEntry>, Option<String>), ToolError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
.build()
.map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?;
// Step 1: fetch the HTML page to obtain the `vqd` token used by the images API.
let encoded = url_encode(query);
let seed_url = format!("https://duckduckgo.com/?q={encoded}&iax=images&ia=images");
let seed_resp = client
.get(&seed_url)
.header(
"Accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.header("Accept-Language", "en-US,en;q=0.5")
.send()
.await
.map_err(|e| {
ToolError::execution_failed(format!("Image search seed request failed: {e}"))
})?;
let seed_status = seed_resp.status();
let seed_body = seed_resp.text().await.map_err(|e| {
ToolError::execution_failed(format!("Failed to read image seed response: {e}"))
})?;
if !seed_status.is_success() {
return Err(ToolError::execution_failed(format!(
"Image search seed request failed: HTTP {}",
seed_status.as_u16()
)));
}
let vqd = extract_duckduckgo_vqd(&seed_body).ok_or_else(|| {
ToolError::execution_failed("Failed to extract DuckDuckGo image token (vqd)")
})?;
// Step 2: query the DuckDuckGo images JSON endpoint.
let api_url = format!("https://duckduckgo.com/i.js?l=us-en&o=json&q={encoded}&vqd={vqd}&p=1");
let api_resp = client
.get(&api_url)
.header("Accept", "application/json")
.header("Referer", "https://duckduckgo.com/")
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Image search request failed: {e}")))?;
let api_status = api_resp.status();
let api_body = api_resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Failed to read image response: {e}")))?;
if !api_status.is_success() {
return Err(ToolError::execution_failed(format!(
"Image search failed: HTTP {}",
api_status.as_u16()
)));
}
let parsed: DuckDuckGoImageResponse = serde_json::from_str(&api_body).map_err(|e| {
ToolError::execution_failed(format!("Failed to parse image search JSON: {e}"))
})?;
let mut results = parsed
.results
.into_iter()
.filter(|item| !item.image.trim().is_empty())
.map(|item| ImageResultEntry {
image: item.image,
thumbnail: item.thumbnail,
title: item.title,
url: item.url,
source: item.source,
width: item.width,
height: item.height,
})
.collect::<Vec<_>>();
// Domain filter is applied to the source page URL when available.
let warning = if !domains.is_empty() {
let before = results.len();
results.retain(|entry| match entry.url.as_deref() {
Some(url) => domain_matches(url, domains),
None => true,
});
if before != results.len() {
Some("Filtered image results by domain list".to_string())
} else {
None
}
} else {
None
};
results.truncate(max_results);
Ok((results, warning))
}
fn page_from_search(query: &str, results: &[SearchEntry]) -> WebPage {
let mut lines = Vec::new();
let mut links = Vec::new();
@@ -1069,4 +1337,25 @@ mod tests {
assert!(wrapped.len() > 1);
assert!(wrapped.iter().all(|l| l.len() <= 20));
}
#[test]
fn extracts_duckduckgo_vqd_token() {
let html_single = "<script>var x = {vqd='3-1234567890'};</script>";
assert_eq!(
extract_duckduckgo_vqd(html_single),
Some("3-1234567890".to_string())
);
let html_double = "<script>var x = {vqd=\"3-abcdef\"};</script>";
assert_eq!(
extract_duckduckgo_vqd(html_double),
Some("3-abcdef".to_string())
);
let html_plain = "https://duckduckgo.com/?q=test&vqd=3-xyz_123&ia=images";
assert_eq!(
extract_duckduckgo_vqd(html_plain),
Some("3-xyz_123".to_string())
);
}
}