From 702f9c353c15c5818ce2465291a1e3563b5e9f16 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Thu, 5 Feb 2026 09:28:39 -0600 Subject: [PATCH] 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 --- CHANGELOG.md | 12 ++ Cargo.toml | 2 +- PARITY.md | 14 +- README.md | 4 +- src/client.rs | 41 +++++- src/core/engine.rs | 70 ++++++---- src/prompts/agent.txt | 9 +- src/prompts/normal.txt | 2 +- src/prompts/plan.txt | 2 +- src/tools/web_run.rs | 293 ++++++++++++++++++++++++++++++++++++++++- 10 files changed, 409 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a832f43..bb4e2e31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 9f6e0775..09ad7790 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/PARITY.md b/PARITY.md index 37f47266..58c793e9 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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) diff --git a/README.md b/README.md index fae166ca..07f1067a 100644 --- a/README.md +++ b/README.md @@ -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 - **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models - **Context‑aware** – loads project‑specific 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 diff --git a/src/client.rs b/src/client.rs index c895c5e9..1603bf64 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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 diff --git a/src/core/engine.rs b/src/core/engine.rs index 88a3d9c9..5717727f 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -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>, ) -> Result { 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; diff --git a/src/prompts/agent.txt b/src/prompts/agent.txt index 951f21f4..6f13c8b0 100644 --- a/src/prompts/agent.txt +++ b/src/prompts/agent.txt @@ -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 diff --git a/src/prompts/normal.txt b/src/prompts/normal.txt index 5e57055b..486278c2 100644 --- a/src/prompts/normal.txt +++ b/src/prompts/normal.txt @@ -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 diff --git a/src/prompts/plan.txt b/src/prompts/plan.txt index f5f9e31a..4529a6a1 100644 --- a/src/prompts/plan.txt +++ b/src/prompts/plan.txt @@ -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 diff --git a/src/tools/web_run.rs b/src/tools/web_run.rs index 5443d239..c866e0fa 100644 --- a/src/tools/web_run.rs +++ b/src/tools/web_run.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + height: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct ImageQueryResult { + query: String, + source: String, + count: usize, + results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, +} + #[derive(Debug, Clone, Serialize, Default)] struct WebRunOutput { #[serde(skip_serializing_if = "Option::is_none")] search_query: Option>, #[serde(skip_serializing_if = "Option::is_none")] + image_query: Option>, + #[serde(skip_serializing_if = "Option::is_none")] open: Option>, #[serde(skip_serializing_if = "Option::is_none")] click: Option>, @@ -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::>() + }) + .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, +} + +#[derive(Debug, Clone, Deserialize)] +struct DuckDuckGoImageResult { + image: String, + #[serde(default)] + thumbnail: Option, + #[serde(default)] + title: Option, + #[serde(default)] + url: Option, + #[serde(default)] + source: Option, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, +} + +fn extract_duckduckgo_vqd(html: &str) -> Option { + 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, Option), 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::>(); + + // 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 = ""; + assert_eq!( + extract_duckduckgo_vqd(html_single), + Some("3-1234567890".to_string()) + ); + + let html_double = ""; + 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()) + ); + } }