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 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/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/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 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. diff --git a/cli/VERSION b/cli/VERSION index eca07e4c..ac2cdeba 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -2.1.2 +2.1.3 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 } diff --git a/desktop/package.json b/desktop/package.json index 408d8e68..6fb438a8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -104,6 +104,7 @@ }, "nsis": { "oneClick": false, + "perMachine": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, "createStartMenuShortcut": true 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..76ee24cc 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -229,6 +229,17 @@ 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) + // Drop window-all-closed handlers first: a lingering handler can keep the + // process alive and stop the installer from replacing files / relaunching + // (a documented electron-updater gotcha, esp. on Windows NSIS). + app.removeAllListeners('window-all-closed') + // isSilent=TRUE on Windows. Our installer is now ASSISTED (nsis.oneClick=false + // + allowToChangeInstallationDirectory) so the FIRST install shows the + // directory/mode wizard. But an UPDATE must NOT re-show that wizard — isSilent + // skips it and updates in place. isForceRunAfter=true relaunches after the + // silent update. (The old assisted+silent force-run bug, #2179, was fixed + // upstream in PR #2278; we're on electron-updater 6.8.9, well past it.) + // setImmediate + removeAllListeners are the documented prerequisites for the + // relaunch to fire reliably. macOS ignores isSilent entirely. + setImmediate(() => autoUpdater.quitAndInstall(true, true)) } diff --git a/desktop/src/renderer/src/components/UpdateBanner.tsx b/desktop/src/renderer/src/components/UpdateBanner.tsx index b51d1e8b..78c48006 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,21 +12,46 @@ 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 + // Full-screen "installing…" overlay: bridges the otherwise blank window + // between clicking "restart to install" and the app actually quitting to + // swap the bundle. (The gap AFTER quit, before relaunch, is OS-level and + // can't be covered.) + if (state.installing) { + return ( +
+ +

{t('update_installing')}

+
+ ) + } + + // 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 preparing = state.preparing const downloading = status?.state === 'downloading' const downloaded = status?.state === 'downloaded' + // macOS emits a second progress pass (verify) after hitting 100%; show it as + // an indeterminate "verifying" state rather than a bar restarting from 0. + const verifying = downloading && state.progressPeaked + const busy = preparing || downloading return (
-

{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 && preparing && ( +
+ + {t('update_preparing')} +
+ )} + + {!errored && downloading && verifying && ( +
+ + {t('update_verifying')} +
+ )} + + {!errored && downloading && !verifying && (
@@ -49,7 +106,7 @@ const UpdateBanner: React.FC = () => {
)} - {!downloading && !downloaded && ( + {!errored && !busy && !downloaded && ( )} - {downloaded && ( + {!errored && downloaded && (