Merge branch 'zhayujie:master' into master

This commit is contained in:
anomixer
2026-07-07 23:21:17 +08:00
committed by GitHub
18 changed files with 870 additions and 65 deletions

View File

@@ -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 <cli.js>` 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

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -1 +1 @@
2.1.2
2.1.3

View File

@@ -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
}

View File

@@ -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_<NAME>_<KEY> 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
}

View File

@@ -104,6 +104,7 @@
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true

View File

@@ -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.

View File

@@ -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))
}

View File

@@ -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 (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-3 bg-base/90 backdrop-blur-sm">
<Loader2 size={28} className="animate-spin text-accent" />
<p className="text-sm text-content-secondary">{t('update_installing')}</p>
</div>
)
}
// 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 (
<div className="absolute bottom-2 left-2 right-2 z-40">
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-content">{t('update_available')}</p>
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
<p className="text-[13px] font-semibold text-content">
{errored ? t('update_failed') : t('update_available')}
</p>
{!errored && version && (
<p className="text-xs text-content-tertiary mt-0.5">v{version}</p>
)}
</div>
<button
onClick={() => state.dismiss()}
@@ -37,7 +62,39 @@ const UpdateBanner: React.FC = () => {
</button>
</div>
{downloading && (
{errored && (
<div className="space-y-2.5">
<div className="flex items-start gap-2 text-xs text-content-secondary">
<AlertTriangle size={13} className="text-amber-500 flex-shrink-0 mt-0.5" />
<span className="break-words">
{status?.state === 'error' ? status.message : ''}
</span>
</div>
<button
onClick={() => state.download()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
>
<RefreshCw size={15} />
{t('update_retry')}
</button>
</div>
)}
{!errored && preparing && (
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_preparing')}</span>
</div>
)}
{!errored && downloading && verifying && (
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_verifying')}</span>
</div>
)}
{!errored && downloading && !verifying && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-content-secondary">
<Loader2 size={13} className="animate-spin" />
@@ -49,7 +106,7 @@ const UpdateBanner: React.FC = () => {
</div>
)}
{!downloading && !downloaded && (
{!errored && !busy && !downloaded && (
<button
onClick={() => state.download()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
@@ -59,7 +116,7 @@ const UpdateBanner: React.FC = () => {
</button>
)}
{downloaded && (
{!errored && downloaded && (
<button
onClick={() => state.install()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"

View File

@@ -72,11 +72,16 @@ const translations: Record<string, Record<string, string>> = {
update_available: '发现新版本',
update_download: '下载更新',
update_downloading: '正在下载',
update_preparing: '正在准备…',
update_verifying: '正在校验,即将完成…',
update_installing: '正在安装并重启,请稍候…',
update_restart: '重启以更新',
update_later: '稍后',
update_latest: '已是最新版本',
update_check: '检查更新',
update_checking: '正在检查…',
update_failed: '更新失败',
update_retry: '重试',
menu_more: '更多',
menu_theme_light: '浅色模式',
menu_theme_dark: '深色模式',
@@ -137,7 +142,7 @@ const translations: Record<string, Record<string, string>> = {
example_task_title: '定时任务',
example_task_text: '1分钟后提醒我检查服务器',
example_code_title: '编程助手',
example_code_text: '搜索AI资讯生成可视化网页报告',
example_code_text: '搜索AI资讯生成可视化网页报告',
example_knowledge_title: '知识库',
example_knowledge_text: '查看知识库当前文档情况',
example_skill_title: '技能系统',
@@ -442,11 +447,16 @@ const translations: Record<string, Record<string, string>> = {
update_available: 'New version available',
update_download: 'Download update',
update_downloading: 'Downloading',
update_preparing: 'Preparing…',
update_verifying: 'Verifying, almost done…',
update_installing: 'Installing and restarting, please wait…',
update_restart: 'Restart to update',
update_later: 'Later',
update_latest: 'You are up to date',
update_check: 'Check for updates',
update_checking: 'Checking…',
update_failed: 'Update failed',
update_retry: 'Retry',
menu_more: 'More',
menu_theme_light: 'Light mode',
menu_theme_dark: 'Dark mode',

View File

@@ -155,9 +155,11 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
const checkUpdate = () => {
setCheckedManually(true)
// Re-open the update panel if an update is already known; also kicks a
// fresh check. Closing the menu so the re-opened panel is visible.
setMenuOpen(false)
// If an update is already known, recheck() re-opens its panel, so close the
// menu to reveal it. Otherwise keep the menu OPEN: the "up to date" result
// shows inline as the menu label — closing it (which resets checkedManually)
// is exactly what made the box flash and never show "up to date".
if (availableUpdate) setMenuOpen(false)
updateState.recheck()
}

View File

@@ -1,5 +1,15 @@
import React, { useEffect, useRef, useCallback, useState } from 'react'
import { ChevronUp, Loader2 } from 'lucide-react'
import {
ChevronUp,
Loader2,
FolderOpen,
Clock,
Code2,
BookOpen,
Puzzle,
Terminal,
type LucideIcon,
} from 'lucide-react'
import MessageBubble from '../components/MessageBubble'
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
import { t } from '../i18n'
@@ -15,13 +25,20 @@ interface ChatPageProps {
// Welcome-screen suggestion cards (aligned with the web console: 6 cards).
// `send` overrides the text dropped into the input (e.g. show "查看全部命令"
// but fill "/help"); otherwise the card's *_text is used.
const SUGGESTIONS: { key: string; send?: string }[] = [
{ key: 'example_sys' },
{ key: 'example_task' },
{ key: 'example_code' },
{ key: 'example_knowledge' },
{ key: 'example_skill' },
{ key: 'example_web', send: '/help' },
// Icon + accent color per card, aligned with the web console palette.
const SUGGESTIONS: {
key: string
send?: string
icon: LucideIcon
iconClass: string
bgClass: string
}[] = [
{ key: 'example_sys', icon: FolderOpen, iconClass: 'text-blue-500', bgClass: 'bg-blue-500/10' },
{ key: 'example_task', icon: Clock, iconClass: 'text-amber-500', bgClass: 'bg-amber-500/10' },
{ key: 'example_code', icon: Code2, iconClass: 'text-emerald-500', bgClass: 'bg-emerald-500/10' },
{ key: 'example_knowledge', icon: BookOpen, iconClass: 'text-violet-500', bgClass: 'bg-violet-500/10' },
{ key: 'example_skill', icon: Puzzle, iconClass: 'text-rose-500', bgClass: 'bg-rose-500/10' },
{ key: 'example_web', send: '/help', icon: Terminal, iconClass: 'text-content-tertiary', bgClass: 'bg-content-tertiary/10' },
]
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
@@ -228,7 +245,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
{SUGGESTIONS.map(({ key, send }) => (
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
<button
key={key}
onClick={() => {
@@ -236,10 +253,17 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const draft = send ?? t(`${key}_text` as Parameters<typeof t>[0])
inputResetRef.current?.(draft, [])
}}
className="text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
className="group text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
>
<div className="font-medium text-sm text-content mb-1">
{t(`${key}_title` as Parameters<typeof t>[0])}
<div className="flex items-center gap-2 mb-1.5">
<span
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${bgClass}`}
>
<Icon size={15} className={iconClass} />
</span>
<span className="font-medium text-sm text-content">
{t(`${key}_title` as Parameters<typeof t>[0])}
</span>
</div>
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
{t(`${key}_text` as Parameters<typeof t>[0])}

View File

@@ -8,6 +8,18 @@ interface UpdateState {
version: string | null
/** Download progress 0-100 while state === 'downloading'. */
percent: number
/** User clicked "download" but no progress event has arrived yet. Gives the
* button an instant busy state so it can't be clicked again during the 1-2s
* lead-up to the first progress event. Cleared on the first 'downloading'. */
preparing: boolean
/** The download progress already reached ~100% once. macOS (Squirrel.Mac)
* emits a SECOND progress pass (verify / block-map) after the first, which
* used to make the bar visibly restart from 0. Once peaked we render an
* indeterminate "verifying" state instead of a confusing second bar. */
progressPeaked: boolean
/** User clicked "restart to install"; show a full-screen "installing…"
* overlay for the brief window before the app quits to swap the bundle. */
installing: boolean
/** User dismissed the badge for this version (don't nag again until next). */
dismissedVersion: string | null
/** Whether the update panel is currently shown. Lifted here so the "check
@@ -33,15 +45,27 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
status: null,
version: null,
percent: 0,
preparing: false,
progressPeaked: false,
installing: false,
dismissedVersion: null,
panelOpen: false,
setStatus: (s) =>
set(() => {
set((st) => {
// A newly detected version auto-opens the panel.
if (s.state === 'available') return { status: s, version: s.version, percent: 0, panelOpen: true }
if (s.state === 'downloading') return { status: s, percent: s.percent }
if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 }
if (s.state === 'available')
return { status: s, version: s.version, percent: 0, preparing: false, progressPeaked: false, panelOpen: true }
if (s.state === 'downloading') {
// First real progress event clears the "preparing" busy state. Track
// when we've hit ~100% so the Squirrel.Mac second pass renders as an
// indeterminate "verifying" state instead of a bar restarting from 0.
const peaked = st.progressPeaked || s.percent >= 99
return { status: s, percent: s.percent, preparing: false, progressPeaked: peaked }
}
if (s.state === 'downloaded')
return { status: s, version: s.version, percent: 100, preparing: false }
if (s.state === 'error') return { status: s, preparing: false, installing: false }
return { status: s }
}),
@@ -50,20 +74,29 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
closePanel: () => set({ panelOpen: false }),
recheck: () => {
const st = get()
// If we already know about an available/downloaded update, just re-open the
// panel (clearing the dismiss for this version) instead of waiting on a
// network round-trip — the user asked to see it.
if (hasAvailableUpdate(st)) {
set({ dismissedVersion: null, panelOpen: true })
}
// Clear any dismiss so a known update surfaces again. Only re-open the panel
// when an update actually exists — if we're already up to date, opening the
// panel would just flash it (the banner renders nothing for not-available)
// and the "up to date" feedback belongs in the menu, not a panel. The menu
// (NavRail) decides whether to close itself + show the panel based on this.
const known = hasAvailableUpdate(get())
set({ dismissedVersion: null, panelOpen: known })
// Always kick a fresh check too (picks up newer versions / recovers errors).
// Pass the UI language so downloads route to the China CDN / R2 accordingly.
window.electronAPI?.checkForUpdate?.(getLang())
},
download: () => window.electronAPI?.downloadUpdate?.(getLang()),
install: () => window.electronAPI?.installUpdate?.(),
download: () => {
// Enter a busy state immediately so the button can't be clicked twice while
// we wait (1-2s) for the first download-progress event to arrive.
set({ preparing: true, progressPeaked: false })
window.electronAPI?.downloadUpdate?.(getLang())
},
install: () => {
// Show the "installing…" overlay before the app quits to swap the bundle.
set({ installing: true })
window.electronAPI?.installUpdate?.()
},
}))
// Subscribe to main-process update events. Returns an unsubscribe fn.

View File

@@ -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)

View File

@@ -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()