From 96b1fccf76c00b10aadecc251f3b72ce524b8cf4 Mon Sep 17 00:00:00 2001 From: fengyl07 <1059732235@qq.com> Date: Tue, 7 Jul 2026 16:00:31 +0800 Subject: [PATCH 01/13] feat(mcp): add stateless on-demand tool retrieval module --- agent/tools/mcp/tool_retrieval.py | 159 ++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 agent/tools/mcp/tool_retrieval.py diff --git a/agent/tools/mcp/tool_retrieval.py b/agent/tools/mcp/tool_retrieval.py new file mode 100644 index 00000000..1a9c8c0c --- /dev/null +++ b/agent/tools/mcp/tool_retrieval.py @@ -0,0 +1,159 @@ +# encoding:utf-8 +""" +On-demand MCP tool retrieval. + +Pure, stateless selection helpers used by the streaming executor to decide +which MCP tools to inject into a given LLM turn. Vector precompute + caching +live in ToolManager (the tool-lifecycle owner, a process-wide singleton); +only the context-aware selection lives here, because only the executor knows +the conversation context. + +Invariants (per maintainer review of the feature proposal): + * Built-in tools are never handled here — the caller injects them in full. + * Any failure / missing input returns None so the caller falls back to + full injection; tools must never be silently dropped. + * Selection is union-accumulated across turns by the caller (only-grows), + so a tool that already produced a tool_use in the message history can + never disappear from the schema mid-run (which would make Claude/MiniMax + raise a message-format error). +""" +import math +from typing import Dict, List, Optional, Sequence, Set + +try: + import numpy as np + _HAS_NUMPY = True +except ImportError: + _HAS_NUMPY = False + +# How many trailing messages to concatenate into the retrieval query. Tool +# needs drift across a multi-turn tool-call loop, so a single (initial) user +# query is not enough; a short recent window captures the drift without +# bloating the query with stale context. +DEFAULT_QUERY_MESSAGES = 5 + + +def build_retrieval_query(messages: list, max_messages: int = DEFAULT_QUERY_MESSAGES) -> str: + """Concatenate the text of the most recent messages into a retrieval query. + + Only ``text`` content blocks are kept; ``tool_use`` / ``tool_result`` blocks + are skipped so the query stays short and focused on natural-language intent + rather than large serialized tool payloads. + + Args: + messages: Claude-style message list, each ``{"role", "content"}`` where + content is either a string or a list of typed blocks. + max_messages: Size of the trailing window to consider. + + Returns: + A single string (possibly empty if no text is found). + """ + if not messages: + return "" + + parts: List[str] = [] + for message in messages[-max_messages:]: + content = message.get("content") if isinstance(message, dict) else None + if isinstance(content, str): + if content.strip(): + parts.append(content.strip()) + continue + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + text = block.get("text", "") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + return "\n".join(parts) + + +def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float: + """Cosine similarity of two equal-length vectors; 0.0 on degenerate input.""" + if not a or not b or len(a) != len(b): + return 0.0 + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(y * y for y in b)) + if norm_a == 0 or norm_b == 0: + return 0.0 + return dot / (norm_a * norm_b) + + +def select_mcp_tools( + query_vector: Optional[Sequence[float]], + tool_vectors: Dict[str, Sequence[float]], + top_k: int, + already_selected: Optional[Set[str]] = None, +) -> Optional[Set[str]]: + """Return the accumulated set of MCP tool names to inject this turn. + + Computes cosine similarity between ``query_vector`` and each candidate + tool vector, keeps the ``top_k`` best, and unions them with + ``already_selected`` so the injected set only ever grows within a run. + + Args: + query_vector: Embedding of the current retrieval query, or None. + tool_vectors: ``{mcp_tool_name: vector}`` for candidate MCP tools. + top_k: Max number of tools to add from this turn's ranking. + already_selected: Names accumulated in previous turns of this run. + + Returns: + The union set of tool names to inject, or None to signal + "fall back to full injection" (no query vector, empty/invalid index, + or any unexpected error). This function never raises. + """ + accumulated: Set[str] = set(already_selected) if already_selected else set() + + if not query_vector or not tool_vectors or top_k <= 0: + return None + + try: + expected_dim = len(query_vector) + # Only rank candidates whose vector dimensionality matches the query. + # A dimension mismatch means the index was built with a different + # embedding model; ranking across dims is meaningless. + candidates = { + name: vec + for name, vec in tool_vectors.items() + if vec and len(vec) == expected_dim + } + if not candidates: + return None + + ranked = _rank_by_similarity(query_vector, candidates) + for name, _score in ranked[:top_k]: + accumulated.add(name) + return accumulated + except Exception: + # Selection must never break the agent — fall back to full injection. + return None + + +def _rank_by_similarity( + query_vector: Sequence[float], + candidates: Dict[str, Sequence[float]], +) -> List[tuple]: + """Return ``[(name, score), ...]`` sorted by descending cosine similarity. + + Uses numpy when available (vectorized, matching the memory-search path), + with a pure-Python fallback so the feature works without numpy installed. + """ + names = list(candidates.keys()) + + if _HAS_NUMPY: + matrix = np.array([candidates[n] for n in names], dtype=np.float32) # (N, D) + q_vec = np.array(query_vector, dtype=np.float32) # (D,) + dots = matrix @ q_vec # (N,) + row_norms = np.linalg.norm(matrix, axis=1) # (N,) + q_norm = float(np.linalg.norm(q_vec)) + denominators = row_norms * q_norm + np.maximum(denominators, 1e-10, out=denominators) # avoid div-by-zero + sims = dots / denominators + order = np.argsort(sims)[::-1] + return [(names[i], float(sims[i])) for i in order] + + scored = [(n, cosine_similarity(query_vector, candidates[n])) for n in names] + scored.sort(key=lambda x: x[1], reverse=True) + return scored From bf0831a6648946d950229faf70de50aabf5f54d1 Mon Sep 17 00:00:00 2001 From: fengyl07 <1059732235@qq.com> Date: Tue, 7 Jul 2026 16:00:55 +0800 Subject: [PATCH 02/13] feat(mcp): cache MCP tool vectors with lazy embedding in ToolManager --- agent/tools/tool_manager.py | 101 ++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/agent/tools/tool_manager.py b/agent/tools/tool_manager.py index 823e8b8e..9f9db462 100644 --- a/agent/tools/tool_manager.py +++ b/agent/tools/tool_manager.py @@ -71,6 +71,22 @@ class ToolManager: if not hasattr(self, '_mcp_active_configs'): # server_name -> normalized config dict, for diff-based reload. self._mcp_active_configs: dict = {} + if not hasattr(self, '_mcp_tool_vectors'): + # mcp_tool_name -> embedding vector, used by on-demand tool + # retrieval. Populated lazily on first retrieval so users who + # never enable the feature pay zero embedding cost. + self._mcp_tool_vectors: dict = {} + if not hasattr(self, '_mcp_vector_lock'): + # Guards incremental index builds so concurrent turns don't + # double-embed the same newly-loaded MCP tools. + self._mcp_vector_lock = threading.Lock() + if not hasattr(self, '_embedding_provider_initialized'): + # The embedding provider is created once, lazily, and reused for + # both tool-index and per-query embeddings. None means keyword-only + # mode (no provider configured) — retrieval then falls back to full + # injection at the caller. + self._embedding_provider_initialized = False + self._embedding_provider = None def load_tools(self, tools_dir: str = "", config_dict=None): """ @@ -574,6 +590,91 @@ class ToolManager: return (sorted(added), sorted(removed)) + # ------------------------------------------------------------------ + # On-demand MCP tool retrieval support + # + # The vector index and the embedding provider are owned here (singleton, + # process-wide, aligned with the MCP tool lifecycle). The context-aware + # selection itself lives in agent.tools.mcp.tool_retrieval, driven by the + # executor which is the only place that knows the conversation context. + # ------------------------------------------------------------------ + + def count_mcp_tools(self) -> int: + """Return the number of currently loaded MCP tools.""" + return len(self._mcp_tool_instances) + + def get_mcp_tool_vectors(self) -> dict: + """Return ``{mcp_tool_name: vector}`` for currently loaded MCP tools. + + Lazily embeds any MCP tools not yet in the cache (MCP servers load + asynchronously, so tools may appear over time). Returns an empty dict + when no embedding provider is available or embedding fails — the caller + then falls back to full injection. Never raises. + """ + try: + self._ensure_mcp_tool_vectors() + except Exception as e: + logger.debug(f"[ToolManager] MCP tool vector build skipped: {e}") + return dict(self._mcp_tool_vectors) + + def embed_query(self, text: str): + """Embed a retrieval query with the shared provider. + + Returns the embedding vector, or None if no provider is available or + the call fails (caller falls back to full injection). Never raises. + """ + if not text: + return None + provider = self._get_embedding_provider() + if provider is None: + return None + try: + return provider.embed_query(text) + except Exception as e: + logger.debug(f"[ToolManager] query embedding failed: {e}") + return None + + def _ensure_mcp_tool_vectors(self) -> None: + """Incrementally embed MCP tools that are not yet cached.""" + # Snapshot to avoid concurrent-mutation while the async loader runs. + current = dict(self._mcp_tool_instances) + missing = [name for name in current if name not in self._mcp_tool_vectors] + if not missing: + return + + provider = self._get_embedding_provider() + if provider is None: + return + + with self._mcp_vector_lock: + # Re-check under lock: another thread may have filled these in. + missing = [name for name in current if name not in self._mcp_tool_vectors] + if not missing: + return + texts = [self._mcp_tool_embed_text(current[name]) for name in missing] + vectors = provider.embed_batch(texts) + for name, vec in zip(missing, vectors): + self._mcp_tool_vectors[name] = vec + + @staticmethod + def _mcp_tool_embed_text(tool) -> str: + """Build the text that represents an MCP tool for embedding.""" + name = getattr(tool, "name", "") or "" + description = getattr(tool, "description", "") or "" + return f"{name}: {description}".strip() + + def _get_embedding_provider(self): + """Lazily create and cache the shared embedding provider (or None).""" + if not self._embedding_provider_initialized: + try: + from agent.memory.embedding import create_default_embedding_provider + self._embedding_provider = create_default_embedding_provider() + except Exception as e: + logger.warning(f"[ToolManager] embedding provider init failed: {e}") + self._embedding_provider = None + self._embedding_provider_initialized = True + return self._embedding_provider + def create_tool(self, name: str) -> BaseTool: """ Get a new instance of a tool by name. From 51bf09208df607318f0e93d63ff121bc9e18326e Mon Sep 17 00:00:00 2001 From: fengyl07 <1059732235@qq.com> Date: Tue, 7 Jul 2026 16:01:26 +0800 Subject: [PATCH 03/13] feat(agent): inject retrieved MCP tools in stream executor --- agent/protocol/agent_stream.py | 72 +++++++++++++++++++++++++++++++++- config-template.json | 5 ++- config.py | 7 ++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index 1eb96e50..920112b2 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -379,6 +379,12 @@ class AgentStreamExecutor: self._emit_event("agent_start") + # Reset the run-scoped MCP tool-retrieval accumulator. On-demand tool + # retrieval only grows this set within a run, so a tool that already + # produced a tool_use never disappears from the schema mid-run (which + # would make Claude/MiniMax raise a message-format error). + self._retrieved_mcp_names = set() + final_response = "" turn = 0 @@ -702,6 +708,70 @@ class AgentStreamExecutor: return final_response + def _select_tools_for_injection(self) -> list: + """Decide which tools to inject into the current LLM turn. + + Built-in tools are ALWAYS injected in full (skills and core flows hard + depend on them). MCP tools are also injected in full UNLESS on-demand + retrieval is enabled AND the MCP tool count exceeds the configured + threshold — then only the most relevant MCP tools are injected, unioned + with those already selected earlier in this run (only-grows, so a tool + that already produced a tool_use never vanishes from the schema). + + Degrades safely: disabled feature, no embedding provider, embedding + failure, count below threshold, or any error → inject all tools. Tools + are never silently dropped. + """ + all_tools = list(self.tools.values()) + try: + from config import conf + if not conf().get("mcp_tool_retrieval_enabled", False): + return all_tools + + from agent.tools.mcp.mcp_tool import McpTool + mcp_tools = [t for t in all_tools if isinstance(t, McpTool)] + builtin_tools = [t for t in all_tools if not isinstance(t, McpTool)] + + threshold = int(conf().get("mcp_tool_retrieval_threshold", 20) or 20) + if len(mcp_tools) <= threshold: + return all_tools + + top_k = int(conf().get("mcp_tool_retrieval_top_k", 10) or 10) + + from agent.tools import ToolManager + from agent.tools.mcp.tool_retrieval import ( + build_retrieval_query, + select_mcp_tools, + ) + + tm = ToolManager() + tool_vectors = tm.get_mcp_tool_vectors() + query = build_retrieval_query(self.messages) + query_vector = tm.embed_query(query) + + selected = select_mcp_tools( + query_vector, + tool_vectors, + top_k, + getattr(self, "_retrieved_mcp_names", set()), + ) + if selected is None: + # No provider / empty index / error → full injection. + return all_tools + + # Persist the accumulated selection for subsequent turns. + self._retrieved_mcp_names = selected + + selected_mcp = [t for t in mcp_tools if t.name in selected] + logger.info( + f"[ToolRetrieval] Injecting {len(builtin_tools)} built-in + " + f"{len(selected_mcp)}/{len(mcp_tools)} MCP tool(s) (top_k={top_k})" + ) + return builtin_tools + selected_mcp + except Exception as e: + logger.debug(f"[ToolRetrieval] full injection (retrieval skipped): {e}") + return all_tools + def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3, _overflow_retry: bool = False) -> Tuple[str, List[Dict]]: """ @@ -742,7 +812,7 @@ class AgentStreamExecutor: tools_schema = None if self.tools: tools_schema = [] - for tool in self.tools.values(): + for tool in self._select_tools_for_injection(): input_schema = tool.params try: dynamic = (tool.get_json_schema() or {}).get("parameters") or {} diff --git a/config-template.json b/config-template.json index 1fa50b24..ad3583bf 100644 --- a/config-template.json +++ b/config-template.json @@ -41,5 +41,8 @@ "enable_thinking": false, "reasoning_effort": "high", "knowledge": true, - "self_evolution_enabled": true + "self_evolution_enabled": true, + "mcp_tool_retrieval_enabled": false, + "mcp_tool_retrieval_threshold": 20, + "mcp_tool_retrieval_top_k": 10 } diff --git a/config.py b/config.py index 921feffc..6fe2db03 100644 --- a/config.py +++ b/config.py @@ -269,6 +269,13 @@ available_setting = { "deep_dream_enabled": True, # scheduled deep dream switch; manual /memory dream is unaffected "skill": {}, # Per-skill runtime config; nested keys flatten to SKILL__ env vars at startup "mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL) + # On-demand MCP tool retrieval: when many MCP tools are connected, inject + # only the most query-relevant ones instead of all of them. Built-in tools + # are always injected in full; degrades to full injection when disabled, + # below threshold, or when no embedding provider is available. + "mcp_tool_retrieval_enabled": False, # switch for on-demand MCP tool retrieval + "mcp_tool_retrieval_threshold": 20, # only retrieve when MCP tool count exceeds this + "mcp_tool_retrieval_top_k": 10, # max relevant MCP tools injected per turn } From f01cc3a0b4abb6bf87390779a9893802312b29fd Mon Sep 17 00:00:00 2001 From: fengyl07 <1059732235@qq.com> Date: Tue, 7 Jul 2026 16:01:38 +0800 Subject: [PATCH 04/13] test(mcp): cover retrieval helpers and executor injection paths --- tests/test_mcp_tool_retrieval.py | 253 +++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 tests/test_mcp_tool_retrieval.py diff --git a/tests/test_mcp_tool_retrieval.py b/tests/test_mcp_tool_retrieval.py new file mode 100644 index 00000000..84aeef7b --- /dev/null +++ b/tests/test_mcp_tool_retrieval.py @@ -0,0 +1,253 @@ +# encoding:utf-8 +""" +Unit tests for on-demand MCP tool retrieval. + +Covers the invariants the maintainer asked for in the feature review: + * below threshold / degrade paths behave exactly like today (full injection), + * the injected MCP tool set only ever grows within a run (never shrinks), +plus the pure selection helpers (query building, cosine, top-k, fallbacks). +""" +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from agent.tools.mcp.tool_retrieval import ( + build_retrieval_query, + cosine_similarity, + select_mcp_tools, +) +from agent.tools.mcp.mcp_tool import McpTool + + +def _mcp_tool(name, description=""): + """Build a real McpTool without needing a live MCP client.""" + return McpTool( + client=None, + tool_schema={"name": name, "description": description, "inputSchema": {}}, + server_name="test-server", + ) + + +class _FakeBuiltinTool: + """Minimal stand-in for a built-in BaseTool.""" + + def __init__(self, name): + self.name = name + self.description = f"builtin {name}" + self.params = {"type": "object", "properties": {}} + + def get_json_schema(self): + return {} + + +class _FakeToolManager: + """Controls the vectors/query embeddings seen by the executor.""" + + def __init__(self, tool_vectors, query_vectors): + self._tool_vectors = tool_vectors + self._query_vectors = list(query_vectors) + self._call = 0 + + def get_mcp_tool_vectors(self): + return dict(self._tool_vectors) + + def embed_query(self, text): + if self._call < len(self._query_vectors): + vec = self._query_vectors[self._call] + else: + vec = self._query_vectors[-1] if self._query_vectors else None + self._call += 1 + return vec + + +# -------------------------------------------------------------------------- +# Pure helper: build_retrieval_query +# -------------------------------------------------------------------------- + +class TestBuildRetrievalQuery(unittest.TestCase): + + def test_empty_messages(self): + self.assertEqual(build_retrieval_query([]), "") + + def test_extracts_text_blocks(self): + messages = [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "world"}]}, + ] + self.assertEqual(build_retrieval_query(messages), "hello\nworld") + + def test_skips_tool_result_and_tool_use(self): + messages = [ + {"role": "user", "content": [{"type": "text", "text": "do it"}]}, + {"role": "assistant", "content": [ + {"type": "tool_use", "name": "read", "input": {}}, + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "content": "huge payload " * 100}, + ]}, + ] + self.assertEqual(build_retrieval_query(messages), "do it") + + def test_string_content_supported(self): + messages = [{"role": "user", "content": "plain string"}] + self.assertEqual(build_retrieval_query(messages), "plain string") + + def test_respects_recent_window(self): + messages = [ + {"role": "user", "content": [{"type": "text", "text": f"m{i}"}]} + for i in range(10) + ] + # Only the last 3 messages should be kept. + self.assertEqual(build_retrieval_query(messages, max_messages=3), "m7\nm8\nm9") + + +# -------------------------------------------------------------------------- +# Pure helper: cosine_similarity +# -------------------------------------------------------------------------- + +class TestCosineSimilarity(unittest.TestCase): + + def test_identical_vectors(self): + self.assertAlmostEqual(cosine_similarity([1.0, 0.0], [1.0, 0.0]), 1.0) + + def test_orthogonal_vectors(self): + self.assertAlmostEqual(cosine_similarity([1.0, 0.0], [0.0, 1.0]), 0.0) + + def test_degenerate_inputs(self): + self.assertEqual(cosine_similarity([], [1.0]), 0.0) + self.assertEqual(cosine_similarity([0.0, 0.0], [1.0, 1.0]), 0.0) + self.assertEqual(cosine_similarity([1.0], [1.0, 0.0]), 0.0) + + +# -------------------------------------------------------------------------- +# Pure helper: select_mcp_tools +# -------------------------------------------------------------------------- + +class TestSelectMcpTools(unittest.TestCase): + + def setUp(self): + self.vectors = { + "a": [1.0, 0.0, 0.0], + "b": [0.0, 1.0, 0.0], + "c": [0.0, 0.0, 1.0], + "d": [0.9, 0.1, 0.0], + } + + def test_returns_top_k(self): + selected = select_mcp_tools([1.0, 0.0, 0.0], self.vectors, top_k=2, + already_selected=set()) + self.assertEqual(selected, {"a", "d"}) + + def test_union_only_grows_across_turns(self): + """The core invariant: a later turn never drops earlier selections.""" + first = select_mcp_tools([1.0, 0.0, 0.0], self.vectors, top_k=2, + already_selected=set()) + second = select_mcp_tools([0.0, 1.0, 0.0], self.vectors, top_k=1, + already_selected=first) + self.assertTrue(first.issubset(second)) + self.assertIn("b", second) + + def test_none_query_vector_falls_back(self): + self.assertIsNone(select_mcp_tools(None, self.vectors, top_k=2, + already_selected=set())) + + def test_empty_index_falls_back(self): + self.assertIsNone(select_mcp_tools([1.0, 0.0, 0.0], {}, top_k=2, + already_selected=set())) + + def test_dimension_mismatch_falls_back(self): + mismatched = {"a": [1.0, 0.0]} # dim 2 vs query dim 3 + self.assertIsNone(select_mcp_tools([1.0, 0.0, 0.0], mismatched, top_k=2, + already_selected=set())) + + +# -------------------------------------------------------------------------- +# Executor integration: AgentStream._select_tools_for_injection +# -------------------------------------------------------------------------- + +class TestSelectToolsForInjection(unittest.TestCase): + """Exercise the executor decision without spinning up a real agent.""" + + def _make_self(self, mcp_count, builtins=("read", "write", "bash")): + from types import SimpleNamespace + tools = {} + for name in builtins: + tools[name] = _FakeBuiltinTool(name) + for i in range(mcp_count): + name = f"mcp_{i}" + tools[name] = _mcp_tool(name, f"tool number {i}") + return SimpleNamespace( + tools=tools, + messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + _retrieved_mcp_names=set(), + ) + + def _call(self, fake_self): + from agent.protocol.agent_stream import AgentStreamExecutor + return AgentStreamExecutor._select_tools_for_injection(fake_self) + + def _conf(self, **overrides): + cfg = { + "mcp_tool_retrieval_enabled": True, + "mcp_tool_retrieval_threshold": 20, + "mcp_tool_retrieval_top_k": 2, + } + cfg.update(overrides) + return cfg + + def test_disabled_returns_all_tools(self): + fake = self._make_self(mcp_count=50) + with patch("config.conf", return_value=self._conf(mcp_tool_retrieval_enabled=False)): + result = self._call(fake) + self.assertEqual(len(result), len(fake.tools)) + + def test_below_threshold_returns_all_tools(self): + """Maintainer scenario 1: below threshold → behavior unchanged.""" + fake = self._make_self(mcp_count=5) # <= threshold 20 + with patch("config.conf", return_value=self._conf()): + result = self._call(fake) + self.assertEqual(len(result), len(fake.tools)) + + def test_degrade_no_provider_returns_all_tools(self): + """Maintainer scenario 2: no embedding provider → full injection.""" + fake = self._make_self(mcp_count=25) # > threshold + fake_tm = _FakeToolManager(tool_vectors={}, query_vectors=[None]) + with patch("config.conf", return_value=self._conf()), \ + patch("agent.tools.ToolManager", return_value=fake_tm): + result = self._call(fake) + self.assertEqual(len(result), len(fake.tools)) + + def test_builtins_always_injected_and_set_grows(self): + """Maintainer scenario 3: multi-turn MCP set only grows; builtins stay.""" + fake = self._make_self(mcp_count=25) + # Deterministic vectors: mcp_0 wins turn 1, mcp_1 wins turn 2. + tool_vectors = {f"mcp_{i}": [0.1, 0.1, 0.1] for i in range(25)} + tool_vectors["mcp_0"] = [1.0, 0.0, 0.0] + tool_vectors["mcp_1"] = [0.0, 1.0, 0.0] + fake_tm = _FakeToolManager( + tool_vectors=tool_vectors, + query_vectors=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + ) + with patch("config.conf", return_value=self._conf(mcp_tool_retrieval_top_k=1)), \ + patch("agent.tools.ToolManager", return_value=fake_tm): + result1 = self._call(fake) + names1 = {t.name for t in result1} + result2 = self._call(fake) + names2 = {t.name for t in result2} + + # Built-in tools present in both turns. + for b in ("read", "write", "bash"): + self.assertIn(b, names1) + self.assertIn(b, names2) + # Turn 1 selected mcp_0; turn 2 must still contain it (only-grows). + self.assertIn("mcp_0", names1) + self.assertIn("mcp_0", names2) + self.assertIn("mcp_1", names2) + self.assertTrue(fake._retrieved_mcp_names >= {"mcp_0", "mcp_1"}) + + +if __name__ == "__main__": + unittest.main() From ed5b2d6ce6e521f1c2f247f27f9f9f427ada871f Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 7 Jul 2026 16:28:48 +0800 Subject: [PATCH 05/13] fix: mac zip artifacts --- .github/workflows/release.yml | 44 +++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5500a3db..0c7ceec9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,15 +108,12 @@ jobs: shell: bash run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version - # Compile renderer + main in its OWN step. On Windows, `npm run build` - # is `npm.cmd` (a batch wrapper); when it runs as a non-final command in a - # Git Bash script it hijacks/terminates the parent bash after the child - # npm scripts finish, so any commands AFTER it (the electron-builder call) - # never run yet the step still exits 0 — which is exactly why past Windows - # runs "succeeded" but produced no installer. Keeping it alone in a step - # sidesteps that entirely. + # Compile renderer + main in its OWN step, alone, so the npm.cmd batch + # wrapper (see the note on the build step below) can't take out anything + # after it. - name: Compile (vite + tsc) working-directory: desktop + shell: bash run: npm run build - name: Build & publish (electron-builder) @@ -141,11 +138,8 @@ jobs: # electron-builder as a broken cert path, so we leave it entirely unset # for an unsigned build. # - # NOTE: no `set -e`/`unset` on env-injected vars here — under Git Bash - # on Windows, `unset` of a GitHub-injected env var can return non-zero - # and, with pipefail/errexit, silently abort the script BEFORE - # electron-builder runs (which is exactly how a past run produced no - # installer yet still reported success). We only ever set, never unset. + # NOTE: we only ever `export`, never `unset`, GitHub-injected env vars + # (an `unset` can return non-zero and abort under errexit). case "${{ matrix.platform }}" in mac) if [ -n "$MAC_CSC_LINK" ]; then @@ -165,14 +159,23 @@ jobs: # (read-only) feed served from R2/D1, which it can't upload to. We mirror # installers to R2 and register them in D1 ourselves (publish-r2 job). # `--publish never` still emits the latest*.yml files. - # Use the dynamic config (electron-builder.js): it populates - # mac.binaries (backend Mach-O files to sign). Notarization is NOT done - # here — Apple's notary service keeps this large bundle "In Progress" - # for hours, so it's decoupled into a manual local step - # (desktop/build/notarize-dmg.sh) run after this build produces the - # signed dmg. The dmg is signed + hardened-runtime, so it only needs a - # ticket stapled later. - npx electron-builder ${{ matrix.eb_flags }} --config electron-builder.js --publish never + # + # CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to + # inject mac.binaries (the backend Mach-O files to hardened-sign for + # notarization) — it's a pure no-op on Windows. Passing --config on + # Windows was what silently broke the Windows build (it produced no + # installer while the job still reported success; Windows worked fine + # before --config was introduced). So Windows uses the plain + # package.json build config and only mac uses the dynamic one. + # + # Invoke via `node ` rather than `npx`: on Windows `npx` is + # npx.cmd (a batch wrapper) and running it from this Git Bash step can + # make bash return before the wrapped process finishes. node skips it. + case "${{ matrix.platform }}" in + mac) config_arg="--config electron-builder.js" ;; + *) config_arg="" ;; + esac + node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never # Upload artifacts regardless of outcome, so a failed run still surfaces # the built installers (and, on success, the notarized+stapled dmg). @@ -184,6 +187,7 @@ jobs: name: cowagent-${{ matrix.platform }}-${{ matrix.arch }} path: | desktop/release/*.dmg + desktop/release/*.zip desktop/release/*.exe desktop/release/*.yml desktop/release/*.blockmap From de9e7f0e84cff279b9159bc145aee832dfbda57b Mon Sep 17 00:00:00 2001 From: xiaweiwei67-stack <293320877+xiaweiwei67-stack@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:45:37 +0800 Subject: [PATCH 06/13] fix(bash): write large-output temp file as UTF-8 When a command's output exceeds DEFAULT_MAX_BYTES the Bash tool spills the full output to a temp file. The file was opened in text mode without an explicit encoding, so it used the platform locale encoding (cp936/GBK on Chinese Windows). Output containing emoji or other characters not representable in that codepage raised UnicodeEncodeError, which propagated out and turned a successful command (exit code 0) into a tool error, discarding all output. Open the temp file with encoding='utf-8', matching the sibling temp file written in _rewrite_long_python_c. Adds a regression test. Co-authored-by: Cursor --- agent/tools/bash/bash.py | 8 +++- tests/test_bash_output_encoding.py | 59 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 tests/test_bash_output_encoding.py diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py index a3652cac..3e2bf307 100644 --- a/agent/tools/bash/bash.py +++ b/agent/tools/bash/bash.py @@ -202,8 +202,12 @@ SAFETY: total_bytes = len(output.encode('utf-8')) if total_bytes > DEFAULT_MAX_BYTES: - # Save full output to temp file - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f: + # Save full output to temp file. encoding='utf-8' is required: + # the default text-mode encoding is the platform locale (e.g. + # cp936/GBK on Chinese Windows), which raises UnicodeEncodeError + # for output containing emoji or other non-locale characters and + # would discard an otherwise successful command result. + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-', encoding='utf-8') as f: f.write(output) temp_file_path = f.name diff --git a/tests/test_bash_output_encoding.py b/tests/test_bash_output_encoding.py new file mode 100644 index 00000000..2c455ffd --- /dev/null +++ b/tests/test_bash_output_encoding.py @@ -0,0 +1,59 @@ +# encoding:utf-8 +""" +Regression test for the Bash tool spilling large output to a temp file. + +When a command's output exceeds DEFAULT_MAX_BYTES the full output is written to +a temp file. That file must be opened with encoding='utf-8'; otherwise it falls +back to the platform locale encoding (e.g. cp936/GBK on Chinese Windows), which +raises UnicodeEncodeError for output containing emoji or other characters not +representable in that codepage. The exception previously propagated out and +turned an otherwise-successful command (exit code 0) into a tool error, losing +all of its output. +""" +import os +import sys +import tempfile +from unittest.mock import patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from agent.tools.bash.bash import Bash + + +def test_large_non_locale_output_is_saved_as_utf8(tmp_path): + tool = Bash({"cwd": str(tmp_path), "safety_mode": False}) + + # Emit ~80KB (> 50KB DEFAULT_MAX_BYTES) of an emoji so the tool spills the + # full output to a temp file. Raw UTF-8 bytes are written from the child so + # the command line stays pure ASCII and the child's own stdout encoding is + # irrelevant. + code = "import sys; sys.stdout.buffer.write((chr(0x1F389) * 20000).encode('utf-8'))" + command = f'"{sys.executable}" -c "{code}"' + + real_named_temp_file = tempfile.NamedTemporaryFile + captured = {} + + def spy(*args, **kwargs): + captured.update(kwargs) + return real_named_temp_file(*args, **kwargs) + + temp_file_path = None + try: + with patch( + "agent.tools.bash.bash.tempfile.NamedTemporaryFile", side_effect=spy + ): + result = tool.execute({"command": command, "timeout": 60}) + + # Command succeeded, so the tool must not report an error. + assert result.status == "success", result.result + + # The temp file must be opened as UTF-8 (the actual fix). + assert captured.get("encoding") == "utf-8" + + # And the emoji must round-trip through the saved file. + temp_file_path = result.result["details"]["full_output_path"] + with open(temp_file_path, encoding="utf-8") as f: + assert "\U0001f389" in f.read() + finally: + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) From 2e742958074a754daf6dfd7391b42b3cb1e6168d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 7 Jul 2026 17:23:10 +0800 Subject: [PATCH 07/13] fix(desktop): auto-update install now actually replaces the app --- desktop/src/main/index.ts | 9 +++- desktop/src/main/updater.ts | 14 ++++++- .../renderer/src/components/UpdateBanner.tsx | 41 +++++++++++++++---- desktop/src/renderer/src/i18n.ts | 4 ++ desktop/src/renderer/src/store/updateStore.ts | 12 +++--- 5 files changed, 62 insertions(+), 18 deletions(-) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index d47052d4..5f9d1301 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -253,7 +253,14 @@ function setupIPC() { setUpdateLanguage(lang) startDownload() }) - ipcMain.handle('update-install', () => quitAndInstall()) + ipcMain.handle('update-install', () => { + // Let the window actually close so the app can fully quit — otherwise the + // close-to-tray handler preventDefault()s it, the process stays alive, and + // Squirrel.Mac can't swap the app bundle (the update silently no-ops and + // relaunching still shows the old version). + isQuitting = true + quitAndInstall() + }) // Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer // to pick a sensible default UI language on first run before any paint. diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index 048e165a..ff286e73 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -229,6 +229,16 @@ function attemptDownload(): void { export function quitAndInstall(): void { if (!app.isPackaged) return log('quitAndInstall: relaunching to install update') - // isSilent=false (show installer), isForceRunAfter=true (relaunch after). - autoUpdater.quitAndInstall(false, true) + // isSilent MUST be true on Windows: our NSIS installer is an "assisted" + // installer (oneClick:false + allowToChangeInstallationDirectory), so a + // non-silent update re-shows the install-dir / install-mode wizard instead of + // updating in place. Silent skips the wizard and updates directly. macOS + // (Squirrel.Mac) ignores isSilent, so false there is fine. + // isForceRunAfter=true relaunches the app once the install finishes. + // Drop window-all-closed handlers first: with an assisted NSIS installer a + // lingering handler can keep the process alive and stop the installer from + // replacing files / relaunching (a documented electron-updater gotcha). + app.removeAllListeners('window-all-closed') + const isSilent = process.platform === 'win32' + autoUpdater.quitAndInstall(isSilent, true) } diff --git a/desktop/src/renderer/src/components/UpdateBanner.tsx b/desktop/src/renderer/src/components/UpdateBanner.tsx index b51d1e8b..bdd45aff 100644 --- a/desktop/src/renderer/src/components/UpdateBanner.tsx +++ b/desktop/src/renderer/src/components/UpdateBanner.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Download, RefreshCw, X, Loader2 } from 'lucide-react' +import { Download, RefreshCw, X, Loader2, AlertTriangle } from 'lucide-react' import { t } from '../i18n' import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore' @@ -12,9 +12,12 @@ const UpdateBanner: React.FC = () => { const available = hasAvailableUpdate(state) const status = state.status + const errored = status?.state === 'error' - // Render nothing when there's no update, or the panel is closed (dismissed). - if (!available || !open) return null + // Show the panel when it's open AND we either know of an update or hit an + // error. Keeping it up on error is important: a failed download must surface + // a visible message instead of silently doing nothing. + if (!open || (!available && !errored)) return null const version = state.version const downloading = status?.state === 'downloading' @@ -25,8 +28,12 @@ const UpdateBanner: React.FC = () => {
-

{t('update_available')}

- {version &&

v{version}

} +

+ {errored ? t('update_failed') : t('update_available')} +

+ {!errored && version && ( +

v{version}

+ )}
- {downloading && ( + {errored && ( +
+
+ + + {status?.state === 'error' ? status.message : ''} + +
+ +
+ )} + + {!errored && downloading && (
@@ -49,7 +74,7 @@ const UpdateBanner: React.FC = () => {
)} - {!downloading && !downloaded && ( + {!errored && !downloading && !downloaded && ( )} - {downloaded && ( + {!errored && downloaded && (