mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Merge branch 'zhayujie:master' into master
This commit is contained in:
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
159
agent/tools/mcp/tool_retrieval.py
Normal file
159
agent/tools/mcp/tool_retrieval.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.1.2
|
||||
2.1.3
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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])}
|
||||
|
||||
@@ -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.
|
||||
|
||||
59
tests/test_bash_output_encoding.py
Normal file
59
tests/test_bash_output_encoding.py
Normal 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)
|
||||
253
tests/test_mcp_tool_retrieval.py
Normal file
253
tests/test_mcp_tool_retrieval.py
Normal 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()
|
||||
Reference in New Issue
Block a user