diff --git a/MANIFEST.in b/MANIFEST.in index bfebf92f3..33fc54410 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,9 @@ recursive-include ms_agent/ *.yaml # agent_hub cross-framework conversion templates (markdown, not yaml) recursive-include ms_agent/agent_hub/default_configs * +# Bundled skills directory (harness skills are not shipped right now). +recursive-include ms_agent/skills * + # Include projects recursive-include projects * diff --git a/README.md b/README.md index 6df9e7e47..710495799 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ The first run of the default agent may install additional dependencies for local Start the terminal interface and enter a task. Use `/help` to see commands for session management and more, and `/quit` to exit: ```bash -ms-agent tui +ms-agent ``` diff --git a/README_ZH.md b/README_ZH.md index 4fb19c7a7..ba91efe9e 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -277,7 +277,7 @@ export MODELSCOPE_API_KEY="your_modelscope_api_key" 启动终端交互界面,输入任务即可开始。使用 `/help` 查看会话管理等命令,使用 `/quit` 退出: ```bash -ms-agent tui +ms-agent ``` diff --git a/docs/en/Components/Config.md b/docs/en/Components/Config.md index 286fd2daa..46398bc5e 100644 --- a/docs/en/Components/Config.md +++ b/docs/en/Components/Config.md @@ -38,7 +38,7 @@ llm: # Large model service backend service: modelscope # Model id - model: Qwen/Qwen3-235B-A22B-Instruct-2507 + model: Qwen/Qwen3.8-Flash-Next # Model api_key modelscope_api_key: # Model base_url diff --git a/docs/en/GetStarted/CLI.md b/docs/en/GetStarted/CLI.md index 26e5a1188..e8004963e 100644 --- a/docs/en/GetStarted/CLI.md +++ b/docs/en/GetStarted/CLI.md @@ -78,9 +78,10 @@ agent YAML configuration. Existing snapshots are retained when this is disabled. ## tui — Terminal UI -Launch an interactive UI in the terminal. +Launch an interactive UI in the terminal. With no subcommand, `ms-agent` is the same as `ms-agent tui`. ```shell +ms-agent ms-agent tui --config path/to/agent.yaml ``` diff --git a/docs/zh/Components/config.md b/docs/zh/Components/config.md index 99f71d6b2..6391a1b2c 100644 --- a/docs/zh/Components/config.md +++ b/docs/zh/Components/config.md @@ -38,7 +38,7 @@ llm: # 大模型服务 backend service: modelscope # 模型 id - model: Qwen/Qwen3-235B-A22B-Instruct-2507 + model: Qwen/Qwen3.8-Flash-Next # 模型 api_key modelscope_api_key: # 模型 base_url diff --git a/docs/zh/GetStarted/cli.md b/docs/zh/GetStarted/cli.md index bb76be973..ec33dd633 100644 --- a/docs/zh/GetStarted/cli.md +++ b/docs/zh/GetStarted/cli.md @@ -73,9 +73,10 @@ ms-agent run --config path/to/agent.yaml --query "你好" ## tui — 终端交互界面 -在终端启动交互式界面。 +在终端启动交互式界面。不带子命令时,`ms-agent` 与 `ms-agent tui` 相同。 ```shell +ms-agent ms-agent tui --config path/to/agent.yaml ``` diff --git a/ms_agent/agent/agent.yaml b/ms_agent/agent/agent.yaml index 2da181d22..216943b9f 100644 --- a/ms_agent/agent/agent.yaml +++ b/ms_agent/agent/agent.yaml @@ -1,6 +1,6 @@ llm: service: modelscope - model: Qwen/Qwen3-235B-A22B-Instruct-2507 + model: Qwen/Qwen3.8-Flash-Next modelscope_api_key: modelscope_base_url: https://api-inference.modelscope.cn/v1 diff --git a/ms_agent/agent/base.py b/ms_agent/agent/base.py index b576744cd..07530147e 100644 --- a/ms_agent/agent/base.py +++ b/ms_agent/agent/base.py @@ -53,7 +53,7 @@ def __init__(self, self.config.output_dir = self.output_dir except Exception: pass - # Merge the work-dir project patch (e.g. a persisted /model override) so + # Merge a work-dir ``.ms_agent/config.yaml`` pin if one exists so # config overrides round-trip from /.ms_agent/config.yaml — # anchored to the project (the work dir), not the config file's # directory. This keeps running a shared/template config from picking up diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index bcecb13f7..14b1f2804 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -48,7 +48,7 @@ from ms_agent.skill.skill_tools import SkillToolSet from ms_agent.tools import ToolManager from ms_agent.ui.events import (ContentDelta, ContentEnd, ContextCompacted, - ErrorRaised, ImageDelivered, PlanEntry, + ErrorRaised, ImageDelivered, Notice, PlanEntry, PlanUpdated, ReasoningDelta, ReasoningEnded, ReasoningStarted, ToolCallCompleted, ToolCallComposing, ToolCallStarted, @@ -72,6 +72,9 @@ # metadata (not this literal), so the placeholder never needs localization. INTERRUPTED_PLACEHOLDER = '[interrupted]' _INTERRUPTED_TOOL_RESULT = '[Interrupted: tool execution was cancelled]' +# The missing-key paragraph is reached from startup and from the first +# message. Print it once for the process. +_credential_notice_sent = False def build_partial_round_records( @@ -213,9 +216,10 @@ def _coerce_enable_snapshots_value(value: Any) -> bool: def resolve_enable_snapshots(config: Any) -> bool: """Resolve whether to take automatic pre-task snapshots. - Disabled by default for all agents. An explicit ``enable_snapshots`` - in config always wins (including string forms like ``\"false\"`` - coerced to boolean). + Default is off: there is no CLI/TUI rollback UI, and snapshotting + the work tree (especially ``$HOME``) burns disk. Set + ``enable_snapshots: true`` to opt in. An explicit value always wins + (including string forms like ``\"false\"`` coerced to boolean). """ if OmegaConf.is_config(config): raw = OmegaConf.select( @@ -266,6 +270,7 @@ def __init__( self.callbacks: List[Callback] = [] self.tool_manager: Optional[ToolManager] = None self.task_manager: Optional[TaskManager] = None + self._tools_cleaned = False self.memory_tools: List[Memory] = [] self.rag: Optional[RAG] = None self.knowledge_search: Optional[SirchmunkSearch] = None @@ -335,6 +340,12 @@ def __init__( # session sidecar or the first build). Drift against this baseline # fires a durable update notice on the next user turn. self._prompt_surface: Optional[Dict[str, str]] = None + # TUI assigns this. Called after the user turn is in hand and before + # it is persisted, and once before the opening create_messages. + # None on WebUI and every other host. + self._on_user_turn = None + self._llm_fp: Optional[str] = None + self._mcp_fp: Optional[str] = None async def prepare_skills(self): """Initialize the skill system from config.skills. @@ -992,6 +1003,7 @@ async def prepare_tools(self): from ms_agent.plugins.runtime import PluginRuntime from ms_agent.utils.workspace_context import resolve_workspace_root + self._tools_cleaned = False self.task_manager = TaskManager() safety_guard, permission_enforcer, perm_config = self._build_permission_objects( @@ -1032,6 +1044,10 @@ async def prepare_tools(self): hook_registries, ) mcp_rt = self.mcp_runtime + if mcp_rt is not None and getattr(self, 'runtime', None) is not None: + # Slash commands only see this Runtime, not the agent. The failure + # record /mcp list and /mcp get read lives on the MCP runtime. + self.runtime._mcp_runtime = mcp_rt if mcp_rt is not None and plugin_mcp_servers: from ms_agent.config.mcp_schema import ResolvedMCPConfig merged_servers = { @@ -1054,7 +1070,7 @@ async def prepare_tools(self): permission_config=perm_config, trust_remote_code=self.trust_remote_code, mcp_callable_check=mcp_rt.is_callable if mcp_rt else None, - mcp_failure_handler=mcp_rt.record_failure if mcp_rt else None, + mcp_failure_handler=self._on_mcp_failure if mcp_rt else None, mcp_unavailable_detail=mcp_rt.unavailable_detail if mcp_rt else None, mcp_success_handler=mcp_rt.record_success if mcp_rt else None, @@ -1073,6 +1089,8 @@ async def prepare_tools(self): if mcp_rt is not None and not mcp_rt.is_started: await mcp_rt.start() await self.tool_manager.connect() + if self.runtime is not None: + self.runtime.tool_manager = self.tool_manager if mcp_rt is not None: mcp_rt.bind_tool_manager(self.tool_manager) await mcp_rt.sync_tools() @@ -1081,13 +1099,36 @@ async def prepare_tools(self): tool.set_task_manager(self.task_manager) async def cleanup_tools(self): - """Cleanup resources used by the tool manager.""" + """Best-effort teardown for MCP transports and extra tools. + + ``streamablehttp_client`` (MCP SDK) holds an anyio cancel scope that + must be exited by the same task that entered it. Cancelling that + owner, or letting ``CancelledError`` leak out of jupyter kernel + shutdown, prints a crash-like traceback on TUI ``/quit``. Those + errors are teardown noise — swallow them here. + """ + if self._tools_cleaned: + return + self._tools_cleaned = True + + async def _quiet(awaitable, label: str) -> None: + try: + await awaitable + except asyncio.CancelledError: + logger.debug('%s interrupted during cleanup', label) + except Exception as exc: # noqa: BLE001 - never fail the session on teardown + logger.debug('%s failed during cleanup: %s', label, exc) + if self.task_manager is not None: - self.task_manager.kill_all() + try: + self.task_manager.kill_all() + except Exception: # noqa: BLE001 + logger.debug('task_manager.kill_all failed during cleanup', + exc_info=True) if self.mcp_runtime is not None: - await self.mcp_runtime.stop() + await _quiet(self.mcp_runtime.stop(), 'mcp_runtime.stop') if self.tool_manager is not None: - await self.tool_manager.cleanup() + await _quiet(self.tool_manager.cleanup(), 'tool_manager.cleanup') # Drain scheduled memory ingestion so a teardown right after the last # turn cannot lose its write. Flush only — memory instances are shared # across agents of the same store (SharedMemoryManager), so CLOSING @@ -1099,8 +1140,15 @@ async def cleanup_tools(self): if flush is not None: try: await flush(timeout=15) - except Exception as e: # noqa: BLE001 - cleanup is best-effort - logger.warning(f'memory flush on cleanup failed: {e}') + except (asyncio.CancelledError, Exception) as e: # noqa: BLE001 + logger.debug('memory flush on cleanup failed: %s', e) + note = getattr(tool, 'user_notice', None) + if note: + self._emit_user_notice(str(note)) + try: + tool.user_notice = None + except Exception: + logger.debug('clear memory notice failed', exc_info=True) @property def stream(self): @@ -2403,9 +2451,192 @@ def _next_chunk(_g=_gen): yield messages + def _emit_user_notice(self, text: str) -> None: + """One line the user can act on. Not a log-level change.""" + if self._event_sink is not None: + from ms_agent.ui.events import Notice + self._event_sink.emit(Notice(level='warning', text=text)) + return + logger.error(text) + + async def _on_mcp_failure( + self, + name: str, + phase: str, + message: str, + *, + tool_name: str | None = None, + exc: BaseException | None = None, + ) -> None: + """Count an MCP failure and, the moment it becomes unusable, say so. + + Quit still cleans up quietly. The sentence belongs on the call that + finds the server gone, once per server. + """ + runtime = self.mcp_runtime + if runtime is None: + return + degraded = await runtime.record_failure( + name, phase, message, tool_name=tool_name, exc=exc) + if not degraded: + return + # Config is unchanged, so the next turn would skip MCP apply. + # Clear the fingerprint and name the server so that turn reconnects. + self._mcp_fp = None + pending = getattr(self, '_mcp_reconnect_names', None) + if pending is None: + pending = set() + self._mcp_reconnect_names = pending + pending.add(name) + from ms_agent.mcp.runtime import degraded_user_notice + self._emit_user_notice(degraded_user_notice(name, exc, message)) + def prepare_llm(self): """Initialize the LLM model from the configuration.""" self.llm: LLM = LLM.from_config(self.config) + from ms_agent.llm.runtime_token import llm_runtime_token + self._llm_fp = llm_runtime_token(self.config) + + def _stub_llm_for_setup(self) -> None: + """Placeholder so slash commands can run before a key exists.""" + from types import SimpleNamespace + model = str( + OmegaConf.select(self.config, 'llm.model', default='') or '') + self.llm = SimpleNamespace( + config=self.config, model=model, _setup_stub=True) + + def _emit_credential_setup(self, exc: BaseException) -> None: + # Startup and the first message both reach this. One process, one copy. + global _credential_notice_sent + if _credential_notice_sent: + return + _credential_notice_sent = True + from ms_agent.llm.credentials import missing_api_key_setup_text + service = str( + OmegaConf.select(self.config, 'llm.service', default='') or '') + text = missing_api_key_setup_text(exc, service) + if self._event_sink is not None: + from ms_agent.ui.events import Notice + self._event_sink.emit(Notice(level='warning', text=text)) + else: + logger.warning(text) + + def _emit_pending_turn_resume(self) -> None: + """The parked sentence is going out now that a client can be built.""" + from ms_agent.llm.credentials import PENDING_TURN_RESUME + if self._event_sink is not None: + from ms_agent.ui.events import Notice + # Not ``info``: that level is drawn as a command panel. This is + # one status line, same weight as the other dim notices. + self._event_sink.emit( + Notice(level='status', text=PENDING_TURN_RESUME)) + else: + logger.info(PENDING_TURN_RESUME) + + def _llm_credentials_present(self) -> bool: + """True when a client can be built, so the waiting sentence can run.""" + from ms_agent.llm.llm import LLM + try: + LLM.from_config(self.config) + except Exception: + return False + return True + + def _bind_memory_llm(self) -> None: + """Point memory tools at the client that was just built.""" + for orch in list(getattr(self, 'memory_tools', None) or []): + set_llm = getattr(orch, 'set_llm', None) + if set_llm is None or self.llm is None: + continue + try: + set_llm(self.llm) + except Exception: + logger.debug('memory llm rebind skipped', exc_info=True) + + async def _ensure_llm_ready(self, messages): + """Build a real LLM after the first prompt, looping on missing keys.""" + from ms_agent.llm.credentials import client_rejected_missing_key + if not getattr(self.llm, '_setup_stub', False) and self.llm is not None: + return messages + self._credential_reprompted = False + waited = False + while True: + try: + self.prepare_llm() + if self.runtime is not None: + self.runtime.llm = self.llm + self._bind_memory_llm() + if (waited and not self._credential_reprompted + and isinstance(messages, str) and messages.strip()): + self._emit_pending_turn_resume() + return messages + except Exception as e: + if not (self._interactive and client_rejected_missing_key(e)): + raise + waited = True + self._stub_llm_for_setup() + if self.runtime is not None: + self.runtime.llm = self.llm + self._emit_credential_setup(e) + from ms_agent.command.interactive import InteractiveSession + session = InteractiveSession( + self._get_command_router(), + source='tui' + if self._input_source is not None else 'cli', + input_source=self._input_source, + event_sink=self._event_sink) + turn = await session.run_turn( + messages=None, + runtime=self.runtime, + retry_when=self._llm_credentials_present) + if turn.action == 'quit': + self.runtime.should_stop = True + return None + if turn.text: + messages = turn.text + self._pending_attachments = turn.attachments + self._credential_reprompted = True + + async def _hold_turn_until_llm_ready(self, messages, step_end_len) -> None: + """A cleared key waits the same way a session that never had one does. + + The line that arrived with no client is not sent and not logged. + The next line the user submits is, once a client can be built. + Slash commands keep working. Quit ends the session. The turn is + not failed, and the yellow setup panel is not used. + """ + if not getattr(self.llm, '_setup_stub', False): + return + pending = None + query = '' + if (len(messages) > step_end_len + and getattr(messages[-1], 'role', None) == 'user'): + pending = messages[step_end_len:] + content = pending[-1].content + query = content if isinstance(content, str) else '' + self._pending_attachments = getattr( + pending[-1], 'attachments', None) + del messages[step_end_len:] + try: + ready = await self._ensure_llm_ready(query) + except Exception: + if pending is not None: + messages.extend(pending) + raise + if self.runtime.should_stop or ready is None: + self.runtime.should_stop = True + return + if pending is not None and not getattr( + self, '_credential_reprompted', False): + messages.extend(pending) + return + if isinstance(ready, str) and ready: + messages.append( + Message( + role='user', + content=ready, + attachments=getattr(self, '_pending_attachments', None) + or [])) def prepare_runtime(self): """Initialize the runtime context.""" @@ -2435,6 +2666,8 @@ def read_history(self, messages: List[Message], runtime = Runtime(llm=self.llm) runtime.from_dict(config.runtime) delattr(config, 'runtime') + if self.mcp_runtime is not None: + runtime._mcp_runtime = self.mcp_runtime else: runtime = self.runtime if _messages[-1].role == 'tool': @@ -2672,7 +2905,14 @@ async def run_loop(self, messages: Union[List[Message], str], # prompt below and InputCallback registration just after. self._interactive = self._resolve_interactive(messages) self.register_callback_from_config() - self.prepare_llm() + from ms_agent.llm.credentials import client_rejected_missing_key + try: + self.prepare_llm() + except Exception as e: + if not (self._interactive and client_rejected_missing_key(e)): + raise + self._stub_llm_for_setup() + self._emit_credential_setup(e) self.prepare_runtime() await self.prepare_tools() await self.prepare_skills() @@ -2715,6 +2955,8 @@ async def run_loop(self, messages: Union[List[Message], str], await self.cleanup_tools() return messages = turn.text + if self._on_user_turn is not None: + await self._on_user_turn(None) # create_messages() below builds the user Message from # this string, so hand the turn's attachments over # out-of-band rather than widening that signature. @@ -2730,6 +2972,11 @@ async def run_loop(self, messages: Union[List[Message], str], 'stdin, or run in an interactive terminal.') messages = piped + messages = await self._ensure_llm_ready(messages) + if self.runtime.should_stop: + await self.cleanup_tools() + return + # Load history and restore state restored_from_log = False if self.session_log is not None: @@ -2814,6 +3061,13 @@ async def run_loop(self, messages: Union[List[Message], str], self.log_output('[' + message.role + ']:') self.log_output(message.content) while not self.runtime.should_stop: + # A cleared key leaves a placeholder. Wait here, before + # step(), the same way a session that never had a key waits. + if getattr(self.llm, '_setup_stub', False): + await self._hold_turn_until_llm_ready( + messages, len(messages)) + if self.runtime.should_stop: + break # Rebuild context view from SessionLog (non-destructive # compression). This is the canonical history for the round, so # it must run before the per-round augmentations below. @@ -2914,6 +3168,14 @@ async def run_loop(self, messages: Union[List[Message], str], await self.loop_callback('after_tool_call', messages) else: await self.after_tool_call(messages) + if (self._on_user_turn is not None + and len(messages) > step_end_len): + await self._on_user_turn(messages) + # Drop happened on this line. Hold before the line is logged, + # so a missing key matches a session that never had one. + await self._hold_turn_until_llm_ready(messages, step_end_len) + if self.runtime.should_stop: + break resume_waiting = False # New user turn (interactive multi-turn): attach the durable # augmentations BEFORE the slice below persists them — same @@ -3004,11 +3266,22 @@ async def run_loop(self, messages: Union[List[Message], str], self.session_log.set_metadata_field('status', 'error') except Exception: pass - if hasattr(self.config, 'help'): + # TUI/WebUI already rendered ErrorRaised. The yaml `help` blurb + # ("A commonly use config…") is for headless CLI, not a second + # crash dump in an interactive session. + if self._event_sink is None and hasattr(self.config, 'help'): logger.error( f'[{self.tag}] Runtime error, please follow the instructions:\n\n {self.config.help}' ) raise e + finally: + # CancelledError / GeneratorExit skip the Exception handler and + # used to leave streamable_http owner tasks for the event-loop + # shutdown to cancel — that is what dumps the MCP SDK traceback. + try: + await self.cleanup_tools() + except (asyncio.CancelledError, Exception): # noqa: BLE001 + logger.debug('run_loop cleanup_tools failed', exc_info=True) async def run( self, messages: Union[List[Message], str], **kwargs diff --git a/ms_agent/agent_hub/_secrets.py b/ms_agent/agent_hub/_secrets.py index abca6d1c3..1963d5dc9 100644 --- a/ms_agent/agent_hub/_secrets.py +++ b/ms_agent/agent_hub/_secrets.py @@ -67,6 +67,11 @@ class Finding(NamedTuple): _VENDOR_TOKEN_RE = re.compile( r'(?``. A leading word stays a subcommand, so a + typo still prints help instead of starting the UI. + """ + rest = list(argv[1:]) + if not rest or rest[0].startswith('-'): + return [argv[0], 'tui', *rest] + return list(argv) + + def run_cmd(): """This is the entrance of the all the cli commands. @@ -19,7 +33,7 @@ def run_cmd(): """ parser = argparse.ArgumentParser( 'ModelScope-agent Command Line tool', - usage='ms-agent []') + usage='ms-agent [] [] (no command starts tui)') subparsers = parser.add_subparsers( help='ModelScope-agent commands helpers') @@ -38,7 +52,8 @@ def run_cmd(): PluginCMD.define_args(subparsers) # unknown args will be handled in config.py - args, unknown = parser.parse_known_args() + argv = _with_default_tui(sys.argv) + args, unknown = parser.parse_known_args(argv[1:]) if not hasattr(args, 'func'): parser.print_help() diff --git a/ms_agent/cli/tui.py b/ms_agent/cli/tui.py index e23bc7c8e..60f979bf7 100644 --- a/ms_agent/cli/tui.py +++ b/ms_agent/cli/tui.py @@ -78,6 +78,7 @@ def execute(self): import importlib.resources as importlib_resources config = self.args.config + explicit_config = bool(config) if not config: # Fall back to the packaged default agent.yaml. default_config = importlib_resources.files('ms_agent').joinpath( @@ -94,4 +95,5 @@ def execute(self): work_dir=self.args.work_dir, emit_events=self.args.emit_events, mcp_server_file=self.args.mcp_server_file, + explicit_config=explicit_config, ) diff --git a/ms_agent/command/builtin/__init__.py b/ms_agent/command/builtin/__init__.py index 1e6733a38..7d8d0996d 100644 --- a/ms_agent/command/builtin/__init__.py +++ b/ms_agent/command/builtin/__init__.py @@ -1,6 +1,11 @@ from ms_agent.command.builtin.config_cmds import register_config_commands from ms_agent.command.builtin.context_cmds import register_context_commands from ms_agent.command.builtin.info_cmds import register_info_commands +from ms_agent.command.builtin.instruction_cmds import ( + register_instruction_commands) +from ms_agent.command.builtin.memory_cmds import register_memory_commands +from ms_agent.command.builtin.resource_cmds import register_resource_commands +from ms_agent.command.builtin.search_cmds import register_search_commands from ms_agent.command.builtin.session_cmds import register_session_commands from ms_agent.command.router import CommandRouter @@ -10,3 +15,7 @@ def register_builtin_commands(router: CommandRouter) -> None: register_info_commands(router) register_config_commands(router) register_context_commands(router) + register_resource_commands(router) + register_search_commands(router) + register_instruction_commands(router) + register_memory_commands(router) diff --git a/ms_agent/command/builtin/config_cmds.py b/ms_agent/command/builtin/config_cmds.py index 353e1fee6..0437ef753 100644 --- a/ms_agent/command/builtin/config_cmds.py +++ b/ms_agent/command/builtin/config_cmds.py @@ -3,10 +3,12 @@ from ms_agent.command.router import CommandRouter from ms_agent.command.types import (CommandContext, CommandDef, CommandResult, CommandResultType) +from ms_agent.command.usage import (arg_error, contains_secret, ledger_file, + status_then_usage) CMD_MODEL = CommandDef( name='model', - description='Show or switch the current model', + description='Show, switch, or manage model providers', category='config', ) @@ -18,16 +20,47 @@ ) +def _model_usage() -> str: + return ( + 'usage:\n' + ' /model show current provider + model\n' + ' /model list saved providers\n' + ' /model list live [provider] fetch chat model ids; preview only\n' + ' /model switch model, keep current provider\n' + ' /model / switch both; must be lowercase\n' + ' /model same; use when contains /\n' + ' /model provider add [key=] [url=] [protocol=openai|anthropic] [name=]\n' + ' add a custom provider, or override a builtin\n' + ' /model provider set [key=] [url=] [protocol=] [name=]\n' + ' patch only the fields you pass\n' + ' /model provider key |clear\n' + ' set or clear the API key\n' + ' /model provider url |clear\n' + ' set or clear the base URL\n' + ' /model provider remove \n' + ' delete a custom provider (builtins stay)\n' + ' /model catalog add \n' + ' pin a model id on that provider list\n' + ' /model catalog remove \n' + ' drop that pinned model id (drop is an alias)\n' + ' /model catalog remove same, on the current provider\n' + ' first column of /model list, e.g. dashscope\n' + ' model id on that provider, e.g. qwen3.8-flash\n' + f'Saved in {ledger_file("settings.json")}.\n' + 'Example: /model dashscope qwen3.8-flash') + + +_CLEAR = frozenset({'clear', '-', 'none'}) + + def _persist_model_to_config(config, new_model: str, service=None): - """Persist the model (and optional service) change to the project patch. - - Writes ``llm.model`` (and ``llm.service`` when a provider switch happened) - into ``/.ms_agent/config.yaml`` rather than mutating the - version-controlled source YAML. Anchored to the **work dir** (``output_dir``) - — the project — not the config file's directory, so running a shared or - packaged template config (e.g. ``demos/``) never scatters a ``.ms_agent/`` - next to it. The work-dir patch is merged back (patch wins) on the next run. - Returns the patch path on success, or None if no dir is known / write fails. + """Write a work-dir ``.ms_agent/config.yaml`` pin (not used by ``/model``). + + ``/model`` does not use this. It writes the shared ``default_model`` (what + the next new chat starts on) and the current session's own model. A folder + pin would force every conversation in the work dir onto one model. This + helper remains for callers that explicitly want that pin; the patch still + outranks the global default when present. """ from omegaconf import OmegaConf @@ -57,75 +90,1004 @@ def _persist_model_to_config(config, new_model: str, service=None): return None +def _mgr(): + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + return ModelSettingsManager(global_home()) + + +def _builtin_ids() -> set[str]: + from ms_agent.llm.spec import get_registry + return {spec.name for spec in get_registry().list_providers()} + + +def _known_provider_ids() -> list[str]: + ids = sorted(_builtin_ids()) + try: + for pid in _mgr().list_custom_providers(): + name = str(pid) + if name not in ids: + ids.append(name) + except Exception: + pass + return ids + + +def _unknown_provider_prefix(arg: str) -> str | None: + """Lowercase ``provider/model`` head that is not a known provider. + + ``MiniMax/MiniMax-M2.1`` stays a model id (the head is not lowercase). + ``dashcope/qwen-max`` looks like a provider typo and must not be saved + as the model name on the current provider. + """ + text = (arg or '').strip() + if '/' not in text: + return None + head, rest = text.split('/', 1) + head, rest = head.strip(), rest.strip() + if not head or not rest or any(ch.isspace() for ch in head): + return None + # A capitalized head is a model id when it case-folds to a real provider + # (MiniMax → minimax). A capitalized typo (Dashcope) does not. + if head != head.lower(): + if _canonical_provider(head) is None: + return head + return None + if _canonical_provider(head): + return None + return head + + +def _canonical_provider(head: str) -> str | None: + from ms_agent.llm.spec import get_registry + spec = get_registry().get(head) + if spec is not None: + return spec.name + try: + for pid in _mgr().list_custom_providers(): + if str(pid).lower() == head.lower(): + return str(pid) + except Exception: + pass + return None + + +def _parse_model_arg(arg: str) -> tuple[str | None, str]: + """Parse a switch argument into ``(provider or None, model)``. + + Model ids from a gateway often contain ``/`` (``MiniMax/MiniMax-M2.1``) + and that prefix can collide with a builtin id (``minimax``). Rules: + + * ``/model dashscope MiniMax/MiniMax-M2.1`` — space form; first token is + a known provider (any case). + * ``/model dashscope/MiniMax/MiniMax-M2.1`` — slash form; the provider + token must be a known id written in lowercase, matching how builtins + are registered. ``MiniMax/…`` therefore stays a model id. + * ``/model openai/gpt-4o`` — lowercase known id, model has no extra rule. + * anything else is a model id on the current provider. + """ + import shlex + text = (arg or '').strip() + if not text: + return None, text + try: + parts = shlex.split(text) + except ValueError: + parts = text.split() + if len(parts) >= 2: + canon = _canonical_provider(parts[0]) + if canon: + return canon, text[len(parts[0]):].strip() + if '/' in text: + head, rest = text.split('/', 1) + head, rest = head.strip(), rest.strip() + if rest and head == head.lower() and _canonical_provider(head): + return _canonical_provider(head), rest + return None, text + + +def _strip_provider_prefix(service: str, model: str) -> str: + from ms_agent.config.model_settings import strip_provider_model_prefix + return strip_provider_model_prefix(service, model) + + +def _repair_runtime_model(config, llm=None): + """Un-glue ``minimax MiniMax-M2.1`` in the live config and settings.json. + + Returns ``(service, display_model)``. Display prefers the runtime + ``llm.model`` label when set, matching ``/model`` with no args. + """ + from omegaconf import OmegaConf + service = str(OmegaConf.select(config, 'llm.service', default='') or '') + cfg_model = str(OmegaConf.select(config, 'llm.model', default='') or '') + live_model = str(getattr(llm, 'model', '') + or '') if llm is not None else '' + cfg_cleaned = _strip_provider_prefix(service, cfg_model) + live_cleaned = _strip_provider_prefix(service, live_model) + if cfg_model and cfg_cleaned != cfg_model: + OmegaConf.update(config, 'llm.model', cfg_cleaned, merge=True) + if llm is not None and live_model and live_cleaned != live_model: + try: + llm.model = live_cleaned + except Exception: + pass + try: + stored = _mgr().get_default_model() or '' + if '/' in stored: + pid, mid = stored.split('/', 1) + mid2 = _strip_provider_prefix(pid, mid) + if mid2 != mid: + _mgr().set_default_model(mid2, provider=pid) + except Exception: + pass + return service, live_cleaned or cfg_cleaned, cfg_cleaned or live_cleaned + + +def _switch_cmd(service, model) -> str: + service = str(service or '').strip() + model = _strip_provider_prefix(service, model) + if not service: + return f'/model {model}' + if ('/' in model) or (' ' in model): + return f'/model {service} {model}' + return f'/model {service}/{model}' + + +def _format_pair(service, model) -> str: + service = str(service or '').strip() or '(none)' + model = _strip_provider_prefix(service, model) or '(none)' + return f'Provider: {service}\nModel: {model}' + + +def _first_keyed_provider(exclude: str | None = None) -> str | None: + try: + mgr = _mgr() + except Exception: + return None + skip = (exclude or '').lower() + for pid, _provider, override in _iter_provider_rows(mgr): + if skip and pid.lower() == skip: + continue + if _effective_api_key(pid, override): + return pid + return None + + +def _live_identity(llm) -> tuple[str, str, str]: + spec = getattr(llm, 'spec', None) + transport = getattr(llm, 'transport', None) + live_provider = str(getattr(spec, 'name', '') or '') + live_model = str(getattr(llm, 'model', '') or '') + live_url = str(getattr(transport, 'base_url', '') or '') + return live_provider, live_model, live_url + + +def _current_model_text(service, model, llm=None) -> str: + raw_service = str(service or '').strip() + service = raw_service or '(none)' + model = _strip_provider_prefix(raw_service, model) or '(none)' + lines = [ + f'Provider: {service}', + f'Model: {model}', + ] + if raw_service and model != '(none)': + lines.append(f'Switch: {_switch_cmd(raw_service, model)}') + try: + override = _mgr().list_custom_providers().get(raw_service) or {} + lines.append( + f'Key: {_mask_key(_effective_api_key(raw_service, override))}' + ) + except Exception: + pass + live_p, live_m, live_url = _live_identity(llm) + if live_p or live_url: + lines.append(f'Live: {live_p or "?"} / {live_m or model}') + if live_url: + lines.append(f'Endpoint: {live_url}') + if live_p and live_p.lower() != service.lower(): + realign = _switch_cmd(live_p, model) + lines.append( + f'WARNING: the running client is {live_p}, not {service}. ' + 'A previous /model switch updated the label but kept the old ' + 'client (usually the new provider has no API key). ' + f'Set a key with /model provider key {service} , or ' + f'realign with {realign}.') + return '\n'.join(lines) + '\n' + + +def _switch_fail_text(target_service, target_model, stay_service, + stay_model) -> str: + target_model = _strip_provider_prefix(target_service or '', target_model) + stay_model = _strip_provider_prefix(stay_service, stay_model) + same = bool(target_service and stay_service + and target_service.lower() == stay_service.lower()) + lines: list[str] = [] + if same: + lines.append(f'Cannot use this model: no usable API key for provider ' + f'{target_service}.') + lines.append('Already on:') + else: + lines.append('Cannot switch to:') + lines.append(_format_pair(target_service, target_model)) + lines.append('No usable API key for that provider.') + lines.append('Still on:') + lines.append(_format_pair(stay_service, stay_model)) + if target_service: + lines.append(f'Set a key: /model provider key {target_service} ') + alt = _first_keyed_provider(exclude=target_service or stay_service) + if alt: + lines.append(f'Or switch to a keyed provider ({alt}):\n' + f' /model {alt} ') + elif (stay_service and target_service + and stay_service.lower() != target_service.lower()): + lines.append( + 'If this id is from another gateway, keep that provider:\n' + f' {_switch_cmd(stay_service, target_model)}') + return '\n'.join(lines) + + +def _mask_key(value) -> str: + """Same redaction as the WebUI provider list. + + No key stays ``missing``. Eight characters or fewer become ``****``. + Longer keys keep the first four and last four characters. + """ + text = str(value or '').strip() + if not text: + return 'missing' + if len(text) <= 8: + return '****' + return f'{text[:4]}****{text[-4:]}' + + +def _iter_provider_rows(mgr): + custom = mgr.list_custom_providers() + order: list[str] = [] + rows: dict = {} + for provider in mgr.list_providers(): + pid = provider['id'] + if pid not in rows: + order.append(pid) + if pid not in rows or provider.get('overrides_builtin'): + rows[pid] = provider + for pid in order: + yield pid, rows[pid], custom.get(pid) or {} + + +def _provider_kind(provider, override) -> str: + if provider.get('builtin') or provider.get('overrides_builtin'): + kind = 'builtin' + if override: + kind += '+override' + return kind + return 'custom' + + +def _effective_api_key(pid: str, override: dict) -> str: + key = str(override.get('api_key') or '').strip() + if key: + return key + from ms_agent.llm.spec import get_registry + spec = get_registry().get(pid) + if spec is None: + return '' + for env_var in spec.api_key_env: + value = os.environ.get(env_var) + if value: + return value + return '' + + +def _runtime_api_key(config, pid: str) -> str: + """Key already on the live agent config, when settings and env have none.""" + from ms_agent.llm.credentials import CredentialResolver + from ms_agent.llm.spec import get_registry + spec = get_registry().get(pid) + if spec is None or getattr(config, 'llm', None) is None: + return '' + try: + return str(CredentialResolver.resolve_api_key(spec, config) + or '').strip() + except Exception: + return '' + + +def _effective_base_url(pid: str, override: dict) -> str: + url = str(override.get('base_url') or '').strip() + if url: + return url + from omegaconf import OmegaConf + + from ms_agent.llm.credentials import CredentialResolver + from ms_agent.llm.spec import get_registry + spec = get_registry().get(pid) + if spec is None: + return '' + return CredentialResolver.resolve_base_url(spec, OmegaConf.create({})) + + +def _effective_protocol(pid: str, provider: dict, override: dict) -> str: + proto = override.get('protocol') or provider.get('protocol') or '' + if proto: + return str(proto) + from ms_agent.llm.spec import get_registry + spec = get_registry().get(pid) + return spec.transport if spec is not None else 'openai' + + +def _live_fetch_note(pid: str, override: dict, provider: dict): + """Fetch chat ids. Returns (ids, dropped, skip_reason).""" + from ms_agent.llm.model_discovery import (fetch_model_ids, + filter_chat_model_ids) + key = _effective_api_key(pid, override) + if not key: + return [], 0, 'skipped (no API key)' + url = _effective_base_url(pid, override) + if not url: + return [], 0, 'skipped (no base url)' + proto = _effective_protocol(pid, provider, override) + raw = fetch_model_ids(url, proto, key) + if not raw: + return [], 0, 'live: (empty — /models failed or none)' + kept, dropped = filter_chat_model_ids(raw) + return kept, dropped, '' + + +def _provider_status_lines(mgr, + *, + live: bool = False, + only: str | None = None) -> list[str]: + from ms_agent.llm.model_discovery import (format_id_list, + format_live_model_lines) + + if live: + lines = ['Providers (live /models; preview only):'] + else: + lines = ['Providers:'] + default = mgr.get_default_model() + if default: + lines.append(f'Default: {default}') + only_id = (only or '').strip().lower() or None + for pid, provider, override in _iter_provider_rows(mgr): + if only_id and pid.lower() != only_id: + continue + catalog = list(override.get('models') or provider.get('models') or []) + kind = _provider_kind(provider, override) + key = _mask_key( + _effective_api_key(pid, override) or override.get('api_key')) + url = override.get('base_url') or '(default)' + proto = _effective_protocol(pid, provider, override) + if live: + live_ids, _dropped, skip = _live_fetch_note( + pid, override, provider) + lines.append(f' {pid} ({kind}):') + lines.append( + f' catalog: {format_id_list(catalog) if catalog else "(none listed)"}' + ) + if skip: + lines.append(f' {skip}') + else: + lines.append(f' live: {len(live_ids)} chat') + lines.extend( + format_live_model_lines(live_ids, indent=' ')) + lines.append(f' protocol={proto} key={key} url={url}') + else: + model_txt = ', '.join(catalog) or '(none listed)' + lines.append(f' {pid} ({kind}): {model_txt}') + lines.append(f' protocol={proto} key={key} url={url}') + if only_id and len(lines) <= (2 if default else 1): + lines.append(f' (no provider named {only})') + from ms_agent.llm.model_discovery import LIST_FOOTER + lines.append(LIST_FOOTER) + return lines + + +def _install_rebuilt_llm(target, rebuilt) -> bool: + """Copy ``rebuilt`` onto ``target`` in place. False if the type is frozen.""" + try: + if type(rebuilt) is not type(target): + target.__class__ = rebuilt.__class__ + target.__dict__ = rebuilt.__dict__ + return True + except TypeError: + return False + + +def _push_provider_to_runtime(ctx: CommandContext, provider_id: str) -> str: + if not ctx.runtime or not ctx.runtime.llm: + return '' + from omegaconf import OmegaConf + + from ms_agent.tui.app import TuiApp + config = ctx.runtime.llm.config + service = str(OmegaConf.select(config, 'llm.service', default='') or '') + if service != provider_id: + return (f' Switch with /model {provider_id} to use this ' + 'provider live.') + TuiApp._apply_provider_credentials(config, overwrite=True) + return '' + + +def _split_kv(tokens: list[str]) -> tuple[list[str], dict[str, str]]: + rest: list[str] = [] + fields: dict[str, str] = {} + for tok in tokens: + if '=' in tok: + key, val = tok.split('=', 1) + fields[key] = val + else: + rest.append(tok) + return rest, fields + + +def _kv_alias(fields: dict[str, str]) -> dict[str, str]: + out = dict(fields) + if 'url' in out and 'base_url' not in out: + out['base_url'] = out.pop('url') + elif 'url' in out: + out.pop('url') + if 'key' in out and 'api_key' not in out: + out['api_key'] = out.pop('key') + elif 'key' in out: + out.pop('key') + return out + + async def cmd_model(ctx: CommandContext) -> CommandResult: if not ctx.runtime or not ctx.runtime.llm: return CommandResult( type=CommandResultType.MESSAGE, content='No active agent.') if not ctx.args: - model = ctx.runtime.llm.model - service = getattr(ctx.runtime.llm.config.llm, 'service', 'unknown') + service, model, _cfg_model = _repair_runtime_model( + ctx.runtime.llm.config, ctx.runtime.llm) + if not service: + service = getattr(ctx.runtime.llm.config.llm, 'service', 'unknown') + if not model: + model = ctx.runtime.llm.model return CommandResult( type=CommandResultType.MESSAGE, - content=( - f'Model: {model}\nService: {service}\n' - 'Switch with: /model or /model /'), + content=status_then_usage( + _current_model_text(service, model, ctx.runtime.llm), + _model_usage()), ) - # Accept both "/model " and "/model /". arg = ctx.args.strip() - service_override = None - new_model = arg - if '/' in arg: - service_override, new_model = arg.split('/', 1) - service_override = service_override.strip() - new_model = new_model.strip() + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + head = parts[0].lower() + if head == 'help' or arg in ('-h', '--help'): + return CommandResult( + type=CommandResultType.MESSAGE, content=_model_usage()) + if head == 'list': + rest = parts[1:] + live = bool(rest) and rest[0].lower() in ('live', '--live', 'fetch') + if rest and not live: + return arg_error( + '/model list live [provider]', + reason='Unknown list option', + ctx=ctx, + ) + only = rest[1] if live and len(rest) > 1 else None + return CommandResult( + type=CommandResultType.MESSAGE, + content='\n'.join( + _provider_status_lines(_mgr(), live=live, only=only)), + ) + if head == 'provider': + return _cmd_model_provider(ctx, parts[1:]) + if head == 'catalog': + return _cmd_model_catalog(ctx, parts[1:]) + return _cmd_model_switch(ctx, arg) + + +def _secret_model_error(ctx: CommandContext) -> CommandResult: + return arg_error( + '/model /', + reason='That looks like an API key, not a model id', + note='Set a key with /model provider key ', + ctx=ctx, + ) + + +_NO_CATALOG = '已保存。这个地址没有返回模型列表,下一次对话才会确认模型名。' +_UNREACHABLE = '这个地址现在连不上,密钥还没向供应商确认。' + + +def _runtime_base_url(config, pid: str) -> str: + """URL already on the live agent config, when settings and env have none.""" + llm = getattr(config, 'llm', None) + if llm is None: + return '' + for attr in ('base_url', f'{pid}_base_url'): + value = str(getattr(llm, attr, '') or '').strip() + if value: + return value + return '' + + +def _probe_saved_provider(pid: str, config=None): + """Catalog probe for the provider as currently saved. None without key+url. + + A key or URL that exists only on the running agent still counts, so a + fake model name is not saved with a plain success line. + """ + from ms_agent.llm.model_discovery import probe_model_catalog + override = _mgr().list_custom_providers().get(pid) or {} + key = _effective_api_key(pid, override) + url = _effective_base_url(pid, override) + if config is not None: + if not key: + key = _runtime_api_key(config, pid) + if not url: + url = _runtime_base_url(config, pid) + if not key or not url: + return None + proto = _effective_protocol(pid, {}, override) + return probe_model_catalog(url, proto, key) + + +def _provider_snapshot(pid: str) -> dict: + entry = _mgr().list_custom_providers().get(pid) or {} + return { + 'api_key': entry.get('api_key'), + 'base_url': entry.get('base_url'), + } + + +def _restore_provider_fields(pid: str, before: dict, fields: set[str]) -> None: + mgr = _mgr() + if 'api_key' in fields: + if before.get('api_key'): + mgr.patch_provider(pid, api_key=before['api_key']) + else: + mgr.patch_provider(pid, clear_api_key=True) + if 'base_url' in fields: + if before.get('base_url'): + mgr.patch_provider(pid, base_url=before['base_url']) + else: + mgr.patch_provider(pid, clear_base_url=True) + + +def _probe_reject_text(pid: str, outcome: str, fields: set[str], + before: dict) -> str: + """User-facing rollback copy. A filled command is a copy aid only.""" + changed_key = 'api_key' in fields + had_key = bool(before.get('api_key')) + if outcome == 'auth': + if changed_key and had_key: + line = '这把密钥用不了,没有换成它。当前仍是原来的密钥。' + elif changed_key: + line = '这把密钥被供应商拒绝了,没有保存。' + else: + line = '这把密钥用不了,没有换过去。当前仍是原来的地址。' + aid = f'/model provider key {pid} ' + else: + if changed_key and not ('base_url' in fields): + if had_key: + line = '这个地址连不上,没有换成它。当前仍是原来的密钥。' + else: + line = '这个地址连不上,没有保存。' + aid = f'/model provider key {pid} ' + else: + line = '这个地址连不上,没有换过去。当前仍是原来的地址。' + aid = f'/model provider url {pid} ' + return f'{line}\n可复制:{aid}' + + +def _gate_provider_write(pid: str, before: dict, + fields: set[str]) -> tuple[str | None, str]: + """Probe after a new key is written. A URL-only write makes no request. + + Returns ``(reject_text, saved_note)``. ``reject_text`` is set only when + the write was rolled back. 401/403 rolls back. A connection failure keeps + the write and returns the unreachable note. + """ + if not fields or 'api_key' not in fields: + return None, '' + probe = _probe_saved_provider(pid) + if probe is None or probe.outcome == 'ok': + return None, '' + if probe.outcome == 'no_catalog': + return None, _NO_CATALOG + if probe.outcome == 'unreachable': + return None, _UNREACHABLE + _restore_provider_fields(pid, before, fields) + return _probe_reject_text(pid, probe.outcome, fields, before), '' + + +def _finish_credential_write(ctx: CommandContext, pid: str, before: dict, + fields: set[str], saved: str) -> CommandResult: + reject, note = _gate_provider_write(pid, before, fields) + if reject: + return CommandResult(type=CommandResultType.MESSAGE, content=reject) + live = _push_provider_to_runtime(ctx, pid) + extra = f' {note}' if note else '' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'{saved}{live}{extra}', + ) + + +def _cmd_model_switch(ctx: CommandContext, arg: str) -> CommandResult: + unknown = _unknown_provider_prefix(arg) + if unknown: + known = ', '.join(_known_provider_ids()) + return arg_error( + '/model /', + reason=f'No provider named {unknown}', + note=f'Known: {known}' + if known else 'Add one with /model provider add ', + ctx=ctx, + ) + service_override, new_model = _parse_model_arg(arg) + if service_override: + service_override = _canonical_provider( + service_override) or service_override from omegaconf import OmegaConf + from ms_agent.tui.app import TuiApp + target = ctx.runtime.llm config = target.config + old_service, _display_model, old_model = _repair_runtime_model( + config, target) + new_model = _strip_provider_prefix(service_override or old_service, + new_model) + if contains_secret(new_model): + return _secret_model_error(ctx) + + def _revert_switch() -> None: + OmegaConf.update(config, 'llm.model', old_model, merge=True) + if old_service: + OmegaConf.update(config, 'llm.service', old_service, merge=True) + TuiApp._apply_provider_credentials(config, overwrite=True) + target.model = old_model + OmegaConf.update(config, 'llm.model', new_model, merge=True) if service_override: OmegaConf.update(config, 'llm.service', service_override, merge=True) + TuiApp._apply_provider_credentials(config, overwrite=True) - # Always apply the cheap in-place update: legacy LLM classes read - # ``self.model`` at generate time, so this alone switches the model for - # them (and keeps behavior unchanged when nothing else is possible). - target.model = new_model + settings_provider = service_override or str( + getattr(getattr(config, 'llm', None), 'service', '') or '') or None + pid = settings_provider or '' + override = _mgr().list_custom_providers().get(pid) or {} + # Settings/env keys are probed. A key that already lives on this agent + # config is enough to keep the switch; the next message builds the client. + if not (_effective_api_key(pid, override) + or _runtime_api_key(config, pid)): + _revert_switch() + return CommandResult( + type=CommandResultType.MESSAGE, + content=_switch_fail_text(service_override, new_model, old_service, + old_model), + ) - # Best-effort: rebuild the LLM so the switch also reaches a provider-router - # transport, which caches model/base_url/api_key at build time (setting - # ``.model`` would not reach it). Adopt the rebuilt state into the existing - # object so every holder of the reference sees it (agent.llm and - # runtime.llm are the same object). If the rebuild can't complete (missing - # credentials, test doubles, ...), keep the in-place update above. - from ms_agent.llm import LLM + from ms_agent.llm.model_discovery import nearby_model_ids + probe = _probe_saved_provider(pid, config) + if probe is not None and probe.outcome in ('auth', 'unreachable'): + _revert_switch() + return CommandResult( + type=CommandResultType.MESSAGE, + content=_probe_reject_text( + pid, probe.outcome, + {'api_key'} if probe.outcome == 'auth' else {'base_url'}, { + 'api_key': override.get('api_key'), + 'base_url': override.get('base_url') + }), + ) + if probe is not None and probe.outcome == 'ok' and new_model not in probe.ids: + _revert_switch() + near = ', '.join(nearby_model_ids(new_model, probe.ids)) or '(none)' + return CommandResult( + type=CommandResultType.MESSAGE, + content=(f'列表里没有 {new_model}。相近的有:{near}。' + '没有改默认模型。'), + ) + target.model = new_model + _mgr().set_default_model(new_model, provider=settings_provider) + # The default is what the next new chat starts on. This chat keeps the + # same choice on its own session record, so coming back later does not + # depend on the default still pointing here. + from ms_agent.project.session import persist_logged_session_model try: - rebuilt = LLM.from_config(config) - except Exception: # noqa: BLE001 - best-effort; in-place update stands - rebuilt = None - if rebuilt is not None and type(rebuilt) is type(target): - target.__dict__ = rebuilt.__dict__ - elif rebuilt is not None: - target.__class__ = rebuilt.__class__ - target.__dict__ = rebuilt.__dict__ - - saved_path = _persist_model_to_config(config, new_model, service_override) - switched = (f'{service_override}/{new_model}' - if service_override else new_model) - content = f'Switched to: {switched}' - if saved_path: - content += f'\nSaved to: {saved_path}' - else: - content += '\n(in-memory only; no project directory to persist to)' + persist_logged_session_model(config, new_model, settings_provider) + except Exception: + import logging + logging.getLogger(__name__).debug( + 'session model persist failed', exc_info=True) + content = 'Switched to:\n' + _current_model_text( + settings_provider, new_model, ctx.runtime.llm) + if probe is not None and probe.outcome == 'no_catalog': + content += '\n' + _NO_CATALOG return CommandResult( type=CommandResultType.MUTATE_STATE, content=content, ) +def _cmd_model_provider(ctx: CommandContext, + tokens: list[str]) -> CommandResult: + mgr = _mgr() + if not tokens: + return CommandResult( + type=CommandResultType.MESSAGE, + content='\n'.join(_provider_status_lines(mgr)), + ) + action = tokens[0].lower() + rest = tokens[1:] + if action in ('list', 'ls'): + return CommandResult( + type=CommandResultType.MESSAGE, + content='\n'.join(_provider_status_lines(mgr)), + ) + if action == 'add': + names, fields = _split_kv(rest) + if not names: + return arg_error( + '/model provider add [key=] [url=] [protocol=] [name=]', + ctx=ctx, + ) + pid = names[0] + fields = _kv_alias(fields) + before = _provider_snapshot(pid) + mgr.add_provider( + pid, + name=fields.get('name') or pid, + protocol=fields.get('protocol') or 'openai', + api_key=fields.get('api_key'), + base_url=fields.get('base_url'), + ) + touched = {k for k in ('api_key', 'base_url') if fields.get(k)} + return _finish_credential_write(ctx, pid, before, touched, + f'Provider {pid} saved.') + if action in ('set', 'update'): + names, fields = _split_kv(rest) + if not names: + return arg_error( + '/model provider set [key=] [url=] [protocol=] [name=]', + ctx=ctx, + ) + pid = names[0] + fields = _kv_alias(fields) + if not fields: + return arg_error( + '/model provider set [key=] [url=] [protocol=] [name=]', + reason='No fields to patch', + ctx=ctx, + ) + before = _provider_snapshot(pid) + mgr.patch_provider( + pid, + name=fields.get('name'), + protocol=fields.get('protocol'), + api_key=fields.get('api_key'), + base_url=fields.get('base_url'), + clear_api_key=fields.get('api_key', None) == '', + clear_base_url=fields.get('base_url', None) == '', + ) + touched = {k for k in ('api_key', 'base_url') if k in fields} + return _finish_credential_write(ctx, pid, before, touched, + f'Updated provider {pid}.') + if action == 'key': + if len(rest) < 2: + return arg_error( + '/model provider key |clear', + ctx=ctx, + ) + pid, value = rest[0], ' '.join(rest[1:]) + if value.lower() in _CLEAR: + mgr.patch_provider(pid, clear_api_key=True) + note = _push_provider_to_runtime(ctx, pid) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Cleared API key for {pid}.{note}', + ) + before = _provider_snapshot(pid) + mgr.patch_provider(pid, api_key=value) + return _finish_credential_write(ctx, pid, before, {'api_key'}, + f'Saved API key for {pid}.') + if action in ('url', 'base_url'): + if len(rest) < 2: + return arg_error( + '/model provider url |clear', + ctx=ctx, + ) + pid, value = rest[0], rest[1] + if value.lower() in _CLEAR: + mgr.patch_provider(pid, clear_base_url=True) + note = _push_provider_to_runtime(ctx, pid) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Cleared base URL for {pid}.{note}', + ) + before = _provider_snapshot(pid) + mgr.patch_provider(pid, base_url=value) + return _finish_credential_write(ctx, pid, before, {'base_url'}, + f'Saved base URL for {pid}.') + if action == 'remove': + if not rest: + return arg_error( + '/model provider remove ', + ctx=ctx, + ) + pid = rest[0] + custom = mgr.list_custom_providers() + if pid in _builtin_ids() and pid not in custom: + return CommandResult( + type=CommandResultType.MESSAGE, + content=(f'Cannot remove builtin provider {pid}. ' + 'Clear its override with /model provider key ' + f'{pid} clear'), + ) + mgr.remove_provider(pid) + extra = '' + if pid in _builtin_ids(): + extra = ' Builtin catalog remains; credential override cleared.' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Removed provider {pid}.{extra}', + ) + return arg_error( + '/model provider add|set|key|url|remove ...', + reason=f'Unknown provider action {action!r}', + note='Type /model for all /model commands', + ctx=ctx, + ) + + +def _current_provider(ctx: CommandContext) -> str: + if not ctx.runtime or not getattr(ctx.runtime, 'llm', None): + return '' + cfg = getattr(ctx.runtime.llm, 'config', None) + llm = getattr(cfg, 'llm', None) + return str(getattr(llm, 'service', '') or '') + + +def _pinned_models(mgr, pid: str) -> list[str]: + entry = mgr.list_custom_providers().get(pid) or {} + return list(entry.get('models') or []) + + +def _pinned_catalog_lines(mgr) -> list[str]: + lines = ['Pinned catalogs:'] + found = False + for pid, _provider, override in _iter_provider_rows(mgr): + models = list(override.get('models') or []) + if not models: + continue + found = True + lines.append(f' {pid}: {", ".join(models)}') + if not found: + lines.append(' (none)') + return lines + + +def _cmd_model_catalog(ctx: CommandContext, + tokens: list[str]) -> CommandResult: + mgr = _mgr() + syntax = '/model catalog add|remove ' + note = 'drop = remove; omit to use the current one' + if not tokens: + lines = _pinned_catalog_lines(mgr) + lines.append(f'Need: {syntax}') + lines.append(note) + return CommandResult( + type=CommandResultType.MESSAGE, content='\n'.join(lines)) + action = tokens[0].lower() + if action == 'drop': + action = 'remove' + if action not in ('add', 'remove'): + return arg_error( + syntax, + reason=f'Unknown catalog action {action!r}', + note=note, + ctx=ctx, + ) + rest = tokens[1:] + inferred = False + if len(rest) >= 2: + pid, model = rest[0], ' '.join(rest[1:]) + elif len(rest) == 1: + pid = _current_provider(ctx) + model = rest[0] + inferred = True + if not pid: + return arg_error( + f'/model catalog {action} ', + reason='No current provider to default to', + note=note, + ctx=ctx, + ) + else: + return arg_error( + f'/model catalog {action} ', + note=note, + ctx=ctx, + ) + where = f'{pid} catalog' + if inferred: + where += ' (current provider)' + if action == 'add': + if contains_secret(model): + return arg_error( + '/model catalog add ', + reason='That looks like an API key, not a model id', + note='Set a key with /model provider key ', + ctx=ctx, + ) + mgr.add_model(pid, model) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Added {model} to {where}.', + ) + if pid not in mgr.list_custom_providers(): + return arg_error( + '/model catalog remove ', + reason=f'No catalog override for {pid}', + note='Pin a model first with /model catalog add', + ctx=ctx, + ) + if not mgr.remove_model(pid, model): + pinned = _pinned_models(mgr, pid) + pinned_txt = ', '.join(pinned) if pinned else '(none)' + return arg_error( + '/model catalog remove ', + reason=f'{model} is not in the {where}', + note=f'Pinned: {pinned_txt}', + ctx=ctx, + ) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Removed {model} from {where}.', + ) + + +def _mask_config_secrets(node) -> None: + """Blank secret-shaped keys, and secret values inside ``headers`` / ``env``. + + Non-secrets in those bags stay visible (``Content-Type``, ``PATH``). + A secret embedded in an ordinary string, such as ``sk-…`` in a URL, is + replaced in place and the rest of the value stays. Upload scrubbing still + blanks the whole bag; this is display only. + """ + from ms_agent.agent_hub._secrets import redact_text + from ms_agent.agent_hub._workspace import SECRET_BAG_KEYS, is_secret_key + from ms_agent.command.usage import contains_secret + + if isinstance(node, dict): + for key, val in list(node.items()): + if key in SECRET_BAG_KEYS and isinstance(val, dict): + masked = {} + for inner, inner_val in val.items(): + if inner_val in (None, ''): + masked[inner] = inner_val + elif is_secret_key(str(inner)) or contains_secret( + str(inner_val)): + masked[inner] = '***' + else: + masked[inner] = inner_val + node[key] = masked + elif is_secret_key(str(key)) and not isinstance(val, (dict, list)): + if val not in (None, ''): + node[key] = '***' + elif isinstance(val, str) and val: + cleaned, hits = redact_text(val) + if hits: + node[key] = cleaned + else: + _mask_config_secrets(val) + elif isinstance(node, list): + for item in node: + _mask_config_secrets(item) + + async def cmd_config(ctx: CommandContext) -> CommandResult: if not ctx.runtime or not ctx.runtime.llm: return CommandResult( @@ -134,11 +1096,8 @@ async def cmd_config(ctx: CommandContext) -> CommandResult: config = ctx.runtime.llm.config from omegaconf import OmegaConf - # mask sensitive info safe = OmegaConf.to_container(config, resolve=True) - for key in list(safe.get('llm', {}).keys()): - if 'key' in key.lower(): - safe['llm'][key] = '***' + _mask_config_secrets(safe) import yaml diff --git a/ms_agent/command/builtin/context_cmds.py b/ms_agent/command/builtin/context_cmds.py index e77bc66a8..927d4f5be 100644 --- a/ms_agent/command/builtin/context_cmds.py +++ b/ms_agent/command/builtin/context_cmds.py @@ -93,9 +93,16 @@ async def cmd_tools(ctx: CommandContext) -> CommandResult: return CommandResult( type=CommandResultType.MESSAGE, content='No tools configured.') + failures = getattr( + getattr(ctx.runtime, 'tool_manager', None), 'connect_failures', + None) or {} lines = [f'Configured tools ({len(tool_names)}):'] for name in sorted(tool_names): - lines.append(f' • {name}') + reason = failures.get(name) if isinstance(failures, dict) else None + if reason: + lines.append(f' • {name} {reason}') + else: + lines.append(f' • {name}') return CommandResult( type=CommandResultType.MESSAGE, content='\n'.join(lines)) diff --git a/ms_agent/command/builtin/instruction_cmds.py b/ms_agent/command/builtin/instruction_cmds.py new file mode 100644 index 000000000..16be1c7eb --- /dev/null +++ b/ms_agent/command/builtin/instruction_cmds.py @@ -0,0 +1,216 @@ +"""Slash commands for AGENTS.md / PROFILE.md (same files WebUI settings write).""" +from __future__ import annotations + +from pathlib import Path + +from ms_agent.command.router import CommandRouter +from ms_agent.command.scope import work_dir_of +from ms_agent.command.types import (CommandContext, CommandDef, CommandResult, + CommandResultType) +from ms_agent.command.usage import arg_error, ledger_file, status_then_usage + +CMD_INSTRUCTION = CommandDef( + name='instruction', + description='Show or set AGENTS.md instructions', + category='config', + aliases=('ins', ), +) + +CMD_PROFILE = CommandDef( + name='profile', + description='Show or set PROFILE.md', + category='config', +) + + +def _ins_usage() -> str: + return ( + 'usage:\n' + ' /instruction\n' + ' /instruction global|project\n' + ' /instruction global|project \n' + ' /instruction global|project clear\n' + f'Global → {ledger_file("AGENTS.md")} ' + '(user region under the seeded header).\n' + 'Project → /.ms_agent/AGENTS.md (never the repo-root AGENTS.md).\n' + 'Takes effect on the next turn (files are read live).' + ) + + +def _profile_usage() -> str: + return ( + 'usage:\n' + ' /profile\n' + ' /profile callme \n' + ' /profile callme clear\n' + ' /profile about \n' + ' /profile about clear\n' + f'Writes {ledger_file("PROFILE.md")} (Call me line + free region). ' + 'Takes effect on the next turn.' + ) + + +_CLEAR = frozenset({'clear', '-', 'none'}) + + +def _work_dir(ctx: CommandContext) -> str | None: + return work_dir_of(ctx) + + +def _preview(text: str, empty: str = '(empty)') -> str: + body = (text or '').strip() + if not body: + return empty + if len(body) > 1200: + return body[:1200] + '\n... (truncated)' + return body + + +def _show_instructions(work_dir: str | None) -> str: + from ms_agent.prompting import workspace_files as wf + lines = [f'Global ({ledger_file("AGENTS.md")}):', _preview(wf.read_global_instruction())] + if work_dir: + lines.extend([ + '', + f'Project ({Path(work_dir) / ".ms_agent" / "AGENTS.md"}):', + _preview(wf.read_project_instruction(work_dir)), + ]) + root = Path(work_dir) / 'AGENTS.md' + try: + root_text = root.read_text(encoding='utf-8', errors='replace') + except OSError: + root_text = '' + if root_text.strip(): + lines.extend([ + '', + '(Repo-root AGENTS.md also exists and is injected, but ' + '/instruction never writes it.)', + ]) + else: + lines.extend(['', 'Project: (no work dir — start TUI with --work-dir)']) + return status_then_usage('\n'.join(lines), _ins_usage()) + + +async def cmd_instruction(ctx: CommandContext) -> CommandResult: + from ms_agent.prompting import workspace_files as wf + + arg = (ctx.args or '').strip() + work_dir = _work_dir(ctx) + if not arg: + return CommandResult( + type=CommandResultType.MESSAGE, content=_show_instructions(work_dir)) + + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + scope = parts[0].lower() + rest = parts[1:] + if scope not in ('global', 'project', 'help', '-h', '--help'): + return arg_error( + '/instruction global|project [text|clear]', + reason=f'Unknown instruction scope {scope!r}', + ctx=ctx, + ) + if scope in ('help', '-h', '--help'): + return CommandResult( + type=CommandResultType.MESSAGE, content=_ins_usage()) + + if scope == 'project' and not work_dir: + return CommandResult( + type=CommandResultType.MESSAGE, + content='No work dir (output_dir). Start TUI with --work-dir.', + ) + + if not rest: + if scope == 'global': + body = wf.read_global_instruction() + label = f'Global ({ledger_file("AGENTS.md")})' + else: + body = wf.read_project_instruction(work_dir) + label = f'Project ({Path(work_dir) / ".ms_agent" / "AGENTS.md"})' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'{label}:\n{_preview(body)}', + ) + + text = '' if len(rest) == 1 and rest[0].lower() in _CLEAR else ' '.join(rest) + if scope == 'global': + wf.write_global_instruction(text) + dest = ledger_file('AGENTS.md') + else: + wf.write_project_instruction(work_dir, text) + dest = str(Path(work_dir) / '.ms_agent' / 'AGENTS.md') + verb = 'cleared' if not text else 'saved' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'{verb.capitalize()} {scope} instruction → {dest}. Next turn uses it.', + ) + + +def _show_profile() -> str: + from ms_agent.prompting import workspace_files as wf + call_me, about = wf.read_profile() + lines = [ + f'Call me: {call_me or "(unset)"}', + 'About:', + _preview(about), + ] + return status_then_usage('\n'.join(lines), _profile_usage()) + + +async def cmd_profile(ctx: CommandContext) -> CommandResult: + from ms_agent.prompting import workspace_files as wf + + arg = (ctx.args or '').strip() + if not arg or arg in ('help', '-h', '--help'): + return CommandResult( + type=CommandResultType.MESSAGE, content=_show_profile()) + + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + action = parts[0].lower() + rest = parts[1:] + if action in ('callme', 'call', 'name'): + if not rest: + call_me, _ = wf.read_profile() + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Call me: {call_me or "(unset)"}', + ) + value = '' if len(rest) == 1 and rest[0].lower() in _CLEAR else ' '.join(rest) + wf.write_profile(call_me=value) + verb = 'cleared' if not value else f'set to {value}' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Call me {verb}. Next turn uses {ledger_file("PROFILE.md")}.', + ) + if action in ('about', 'desc', 'description', 'set'): + if not rest: + _, about = wf.read_profile() + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'About:\n{_preview(about)}', + ) + value = '' if len(rest) == 1 and rest[0].lower() in _CLEAR else ' '.join(rest) + wf.write_profile(description=value) + verb = 'cleared' if not value else 'saved' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Profile about {verb}. Next turn uses {ledger_file("PROFILE.md")}.', + ) + return arg_error( + '/profile callme |clear or /profile about |clear', + reason=f'Unknown profile action {action!r}', + note='Type /profile for all commands', + ctx=ctx, + ) + + +def register_instruction_commands(router: CommandRouter) -> None: + router.register(CMD_INSTRUCTION, cmd_instruction) + router.register(CMD_PROFILE, cmd_profile) diff --git a/ms_agent/command/builtin/memory_cmds.py b/ms_agent/command/builtin/memory_cmds.py new file mode 100644 index 000000000..ae7f8fecf --- /dev/null +++ b/ms_agent/command/builtin/memory_cmds.py @@ -0,0 +1,241 @@ +"""Slash command for memory on/off (same files WebUI personalization / project).""" +from __future__ import annotations + +from dataclasses import replace + +from ms_agent.command.router import CommandRouter +from ms_agent.command.scope import work_dir_of +from ms_agent.command.types import (CommandContext, CommandDef, CommandResult, + CommandResultType) +from ms_agent.command.usage import arg_error, status_then_usage + +CMD_MEMORY = CommandDef( + name='memory', + description='Turn memory on or off', + category='config', +) + +_USAGE = ( + 'usage:\n' + ' /memory\n' + ' /memory on|off (this project)\n' + ' /memory project on|off\n' + ' /memory global on|off (default for newly opened folders)\n' + ' /memory backend file|vector (global default for new folders)\n' + ' /memory global backend file|vector\n' + ' /memory project backend file|vector\n' + 'Project on/off turns memory on for this folder. ' + 'Global on/off is the default for newly opened folders. ' + 'file writes MEMORY.md under /.ms_agent/memory/. ' + 'vector is saved but not started in this session.' +) + +_ON = frozenset({'on', 'true', '1', 'enable', 'enabled'}) +_OFF = frozenset({'off', 'false', '0', 'disable', 'disabled'}) + + +def _work_dir(ctx: CommandContext) -> str | None: + return work_dir_of(ctx) + + +def _pm(): + from ms_agent.project import ProjectManager + from ms_agent.project.paths import global_home + return ProjectManager(base_dir=str(global_home())) + + +def _project(ctx: CommandContext): + work = _work_dir(ctx) + if not work: + return None + return _pm().find_by_path(work) + + +def _status_text(ctx: CommandContext) -> str: + from ms_agent.personalization.settings import PersonalizationSettings + loaded = PersonalizationSettings().load() + g_on = 'on' if loaded.memory_enabled else 'off' + g_be = loaded.memory_backend or 'file' + lines = [ + f'Global default: {g_on} backend={g_be}', + '(applies when a new folder is opened)', + ] + project = _project(ctx) + if project is None: + lines.append('Project: (no work dir)') + else: + p_on = 'on' if project.memory_enabled else 'off' + p_be = project.memory_backend or g_be + lines.append(f'Project: {p_on} backend={p_be} id={project.id}') + return status_then_usage('\n'.join(lines), _USAGE) + + +def _backend_saved(text: str, backend: str) -> str: + if backend == 'vector': + return text + ' 当前会话不会启动 vector。' + return text + + +def _parse_bool(token: str) -> bool | None: + low = token.lower() + if low in _ON: + return True + if low in _OFF: + return False + return None + + +def _arm_memory(ctx: CommandContext) -> None: + """The next user message should try to start file memory.""" + runtime = getattr(ctx, 'runtime', None) + if runtime is not None: + runtime._memory_pending = True + + +async def _apply_live(ctx: CommandContext, project) -> str: + """Arm the next user message. The command does not patch ``Runtime``. + + ``ctx.runtime`` is the session ``Runtime``, which has no config. The + ledger is already saved; ``live_config`` writes ``agent.config`` and + starts or drops the memory tools. + """ + from omegaconf import OmegaConf + + from ms_agent.personalization.memory_apply import apply_project_memory + _arm_memory(ctx) + kind = apply_project_memory(OmegaConf.create({}), project) + if kind == 'vector-unavailable': + return ( + '已保存 vector。当前会话不会启动它。' + '要用文件记忆:/memory project backend file') + return '' + + +async def cmd_memory(ctx: CommandContext) -> CommandResult: + arg = (ctx.args or '').strip() + if not arg or arg in ('help', '-h', '--help', 'status'): + return CommandResult( + type=CommandResultType.MESSAGE, content=_status_text(ctx)) + + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + action = parts[0].lower() + rest = parts[1:] + + if action in ('global', 'project'): + if rest and rest[0].lower() == 'backend': + return _cmd_backend(ctx, rest[1:], scope=action) + return await _cmd_toggle(ctx, action, rest) + if action in _ON or action in _OFF: + return await _cmd_toggle(ctx, action, rest) + if action == 'backend': + return _cmd_backend(ctx, rest) + return arg_error( + '/memory on|off | /memory global|project on|off | ' + '/memory backend file|vector | /memory project backend file|vector', + reason=f'Unknown memory action {action!r}', + note='Type /memory for status and all commands', + ctx=ctx, + ) + + +async def _cmd_toggle(ctx: CommandContext, action: str, + rest: list[str]) -> CommandResult: + from ms_agent.personalization.settings import PersonalizationSettings + + scope = 'project' + token = action + if action in ('global', 'project'): + scope = action + if not rest: + return arg_error( + f'/memory {scope} on|off', ctx=ctx) + token = rest[0] + enabled = _parse_bool(token) + if enabled is None: + return arg_error('/memory on|off', ctx=ctx) + + if scope == 'global': + settings = PersonalizationSettings() + loaded = settings.load() + settings.save(replace(loaded, memory_enabled=enabled)) + state = 'on' if enabled else 'off' + return CommandResult( + type=CommandResultType.MESSAGE, + content=( + f'Global memory default → {state}. ' + 'New folders inherit this; this project is unchanged.'), + ) + + project = _project(ctx) + if project is None: + return CommandResult( + type=CommandResultType.MESSAGE, + content='Project memory needs a work dir (TUI --work-dir).', + ) + updated = _pm().update(project.id, memory_enabled=enabled) + note = await _apply_live(ctx, updated) + state = 'on' if enabled else 'off' + content = f'Project memory → {state}.' + if note: + content = f'{content} {note}' + return CommandResult( + type=CommandResultType.MESSAGE, + content=content, + ) + + +def _cmd_backend( + ctx: CommandContext, + rest: list[str], + scope: str | None = None, +) -> CommandResult: + from ms_agent.personalization.settings import PersonalizationSettings + + tokens = list(rest) + if scope is None: + if tokens and tokens[0].lower() in ('global', 'project'): + scope = tokens.pop(0).lower() + elif tokens and tokens[-1].lower() in ('global', 'project'): + scope = tokens.pop(-1).lower() + else: + scope = 'global' + if not tokens or tokens[0].lower() not in ('file', 'vector'): + return arg_error( + '/memory backend file|vector | /memory project backend file|vector', + ctx=ctx) + backend = tokens[0].lower() + + if scope == 'project': + project = _project(ctx) + if project is None: + return CommandResult( + type=CommandResultType.MESSAGE, + content='Project backend needs a work dir (TUI --work-dir).', + ) + _pm().update(project.id, memory_backend=backend) + _arm_memory(ctx) + return CommandResult( + type=CommandResultType.MESSAGE, + content=_backend_saved( + f'Project memory backend → {backend}.', + backend), + ) + + settings = PersonalizationSettings() + loaded = settings.load() + settings.save(replace(loaded, memory_backend=backend)) + return CommandResult( + type=CommandResultType.MESSAGE, + content=_backend_saved( + f'Global memory backend default → {backend}. ' + 'New folders inherit this; this project is unchanged.', + backend), + ) + + +def register_memory_commands(router: CommandRouter) -> None: + router.register(CMD_MEMORY, cmd_memory) diff --git a/ms_agent/command/builtin/resource_cmds.py b/ms_agent/command/builtin/resource_cmds.py new file mode 100644 index 000000000..993adc5f2 --- /dev/null +++ b/ms_agent/command/builtin/resource_cmds.py @@ -0,0 +1,661 @@ +"""Slash commands for managed MCP servers and skills (same files as WebUI).""" +from __future__ import annotations + +from ms_agent.command.router import CommandRouter +from ms_agent.command.scope import ( + SCOPE_CHOICES, parse_optional_scope, pick_scope, work_dir_of) +from ms_agent.command.types import (CommandContext, CommandDef, CommandResult, + CommandResultType) +from ms_agent.command.usage import (arg_error, ledger_dir, ledger_file, + status_then_usage) + +CMD_MCP = CommandDef( + name='mcp', + description='List, add, or enable MCP servers', + category='config', +) + +CMD_SKILL_MANAGE = CommandDef( + name='skills', + description='List, add, or enable skills', + category='config', + aliases=('skill-mgr', ), +) + + +def _mcp_usage() -> str: + return ( + 'usage:\n' + ' /mcp list [global|project]\n' + ' /mcp get [global|project]\n' + ' /mcp add [global|project] command=\n' + ' /mcp add [global|project] url=\n' + ' /mcp update [global|project] command=|url=\n' + ' /mcp json \n' + ' /mcp enable|disable|remove [global|project]\n' + 'Omitting scope on add writes this folder (project). ' + '`global` is every folder on this machine.\n' + f'This machine: {ledger_file("mcp.json")}. ' + 'This folder: /.ms_agent/mcp.json.' + ) + + +def _skill_usage() -> str: + return ( + 'usage:\n' + ' /skills list\n' + ' /skills add [global|project]\n' + ' /skills enable|disable [global|project]\n' + ' /skills remove [global|project]\n' + f'Directory drop-in: global → {ledger_dir()}/skills, ' + 'project → /.ms_agent/skills. ' + 'Omit scope on add: this machine (global). The TUI menu can also ' + 'place it in this folder. ' + 'Omit scope on enable, disable, or remove: the copy that already ' + 'exists. If both exist, the TUI menu asks which one. ' + 'remove only deletes a managed copy, not auto-discovered skills.' + ) + + +_PROJECT_MCP_NOT_ON_SETTINGS = ( + 'Saved for this folder. Use global to save it for every folder.' +) + + +def _home_work(ctx: CommandContext) -> tuple[str, str | None]: + from ms_agent.project.paths import global_home + return str(global_home()), work_dir_of(ctx) + + +def _parse_scope(tokens: list[str], default: str = 'project') -> tuple[str, list[str]]: + """Last token is the scope, even when it is the only token. + + Used by ``/mcp list``, where there is no server name. + """ + if tokens and tokens[-1] in ('global', 'project'): + return tokens[-1], tokens[:-1] + return default, list(tokens) + + +def _parse_named_scope( + tokens: list[str], + default: str = 'project', +) -> tuple[str, list[str], bool]: + """Scope only when a name precedes ``global`` or ``project``. + + A lone ``project`` is the server name — same rule as ``/skills``. + Returns ``(scope, remaining tokens, explicit)``. + """ + scope, rest = parse_optional_scope(tokens) + return (scope or default), rest, scope is not None + + +def _split_kv(tokens: list[str]) -> tuple[list[str], dict[str, str]]: + rest: list[str] = [] + fields: dict[str, str] = {} + for tok in tokens: + if '=' in tok: + key, val = tok.split('=', 1) + fields[key] = val + else: + rest.append(tok) + return rest, fields + + +def _stdio_fields(fields: dict[str, str]) -> dict: + """Match WebUI: command + args list (shlex-split the command line).""" + import shlex + out = dict(fields) + if 'command' in out: + parts = shlex.split(out['command']) + extra = shlex.split(out.pop('args', '')) if out.get('args') else [] + if parts: + out['command'] = parts[0] + out['args'] = parts[1:] + extra + else: + out['args'] = extra + elif 'url' in out: + out.setdefault('type', 'streamable_http') + return out + + +def _need_work(scope: str, work: str | None) -> CommandResult | None: + if scope == 'project' and not work: + return CommandResult( + type=CommandResultType.MESSAGE, + content='Project scope needs a work dir (TUI --work-dir).', + ) + return None + + +def _connect_failure_reason(ctx, name: str) -> str | None: + """Short reason already recorded for ``name``. Does not open a connection.""" + runtime = getattr(getattr(ctx, 'runtime', None), '_mcp_runtime', None) + if runtime is None: + return None + getter = getattr(runtime, 'get_server', None) + if getter is None: + return None + state = getter(name) + if state is None or getattr(state, 'status', None) != 'error': + return None + reason = getattr(state, 'last_error', None) + if not reason: + return None + text = ' '.join(str(reason).split()) + if len(text) > 80: + text = text[:79] + '…' + return text + + +def _mcp_list_text(mgr, scope: str, ctx=None) -> str: + rows = mgr.list(scope if scope != 'merged' else 'merged') + if not rows: + return 'No MCP servers.' + lines = [f'MCP servers ({scope}):'] + for name, entry in rows.items(): + on = 'on' if entry.get('enabled', True) is not False else 'off' + how = entry.get('command') or entry.get('url') or '?' + reason = None + if ctx is not None: + reason = _connect_failure_reason(ctx, name) + if reason: + lines.append(f' [{on}] {name} {how} {reason}') + else: + lines.append(f' [{on}] {name} {how}') + return '\n'.join(lines) + + +_MCP_GET_SKIP = frozenset({ + 'enabled', + 'source', + 'meta', + 'session_kwargs', + 'httpx_client_factory', + 'encoding', + 'encoding_error_handler', + 'sse_read_timeout', + '_removed', +}) + +_MCP_GET_ORDER = ( + 'type', + 'transport', + 'url', + 'command', + 'args', + 'timeout', + 'headers', + 'env', + 'include', + 'exclude', +) + + +def _mcp_display_scalar(key: str, value, *, bag: bool = False) -> str: + """Show a config value, masking a credential and leaving the rest. + + A secret key name becomes ``***``. Inside ``headers`` / ``env``, a value + that itself looks like a credential does too. An ordinary field such as + ``url`` keeps the host and only replaces the credential inside it. + """ + from ms_agent.agent_hub._secrets import redact_text + from ms_agent.agent_hub._workspace import is_secret_key + from ms_agent.command.usage import contains_secret + + if value is None: + return '' + if isinstance(value, list): + text = ' '.join(str(item) for item in value) + else: + text = str(value) + if text == '': + return '' + if is_secret_key(key) or (bag and contains_secret(text)): + return '***' + cleaned, hits = redact_text(text) + return cleaned if hits else text + + +def _mcp_get_lines(name: str, scope: str, entry: dict) -> list[str]: + on = 'on' if entry.get('enabled', True) is not False else 'off' + lines = [f'{name} ({scope}):', f' enabled: {on}'] + keys = [key for key in _MCP_GET_ORDER if key in entry] + keys.extend( + key for key in entry if key not in _MCP_GET_SKIP and key not in keys) + for key in keys: + value = entry.get(key) + if isinstance(value, dict): + lines.append(f' {key}:') + for inner, inner_val in value.items(): + shown = _mcp_display_scalar(str(inner), inner_val, bag=True) + lines.append(f' {inner}: {shown}') + continue + lines.append(f' {key}: {_mcp_display_scalar(key, value)}') + return lines + + +def _mcp_get(ctx, tokens: list[str], mgr, work: str | None) -> CommandResult: + """One server's saved config. Both copies are shown when the name is in each.""" + scope, rest = parse_optional_scope(tokens) + if not rest: + return arg_error('/mcp get [global|project]', ctx=ctx) + if scope == 'project': + miss = _need_work(scope, work) + if miss: + return miss + name = ' '.join(rest) + scopes = (scope, ) if scope else ('global', 'project') + blocks: list[str] = [] + for item_scope in scopes: + if item_scope == 'project' and not work: + continue + try: + rows = mgr.list(item_scope) + except Exception: # noqa: BLE001 - missing root is an empty list + rows = {} + entry = rows.get(name) + if entry: + blocks.append('\n'.join(_mcp_get_lines(name, item_scope, entry))) + if not blocks: + where = f' in {scope}' if scope else '' + return arg_error( + '/mcp get [global|project]', + reason=f'No MCP server named {name}{where}', + ctx=ctx, + ) + content = '\n\n'.join(blocks) + reason = _connect_failure_reason(ctx, name) + if reason: + from ms_agent.mcp.runtime import mcp_unreachable_notice + content = content + '\n' + mcp_unreachable_notice(name, reason) + return CommandResult( + type=CommandResultType.MESSAGE, + content=content, + ) + + +async def cmd_mcp(ctx: CommandContext) -> CommandResult: + arg = (ctx.args or '').strip() + home, work = _home_work(ctx) + from ms_agent.config.mcp_manager import MCPConfigManager + mgr = MCPConfigManager(home, work) + if not arg: + return CommandResult( + type=CommandResultType.MESSAGE, + content=status_then_usage( + _mcp_list_text(mgr, 'merged', ctx), _mcp_usage()), + ) + if arg in ('help', '-h', '--help'): + return CommandResult(type=CommandResultType.MESSAGE, content=_mcp_usage()) + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + action = parts[0].lower() + tokens = parts[1:] + + if action == 'list': + scope, rest = _parse_scope(tokens, default='merged') + if rest: + return arg_error( + '/mcp list [global|project]', + reason='Unknown list option', + ctx=ctx, + ) + if scope not in ('global', 'project', 'merged'): + scope = 'merged' + note = '' + if scope == 'project': + miss = _need_work(scope, work) + if miss: + return miss + if scope in ('global', 'project'): + other = 'global' if scope == 'project' else 'project' + try: + here = mgr.list(scope) + other_rows = mgr.list(other) + except Exception: # noqa: BLE001 - missing root is an empty list + here, other_rows = {}, {} + if scope not in here and scope in other_rows: + note = f'名叫 {scope} 的服务器不在这份清单里。\n' + return CommandResult( + type=CommandResultType.MESSAGE, + content=note + _mcp_list_text(mgr, scope, ctx), + ) + + if action == 'get': + return _mcp_get(ctx, tokens, mgr, work) + + if action == 'json': + if not tokens: + return arg_error('/mcp json ', ctx=ctx) + n = mgr.import_cursor_format(tokens[0]) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Imported {n} server(s).', + ) + + if action in ('enable', 'disable', 'remove'): + if not tokens: + return arg_error( + f'/mcp {action} [global|project]', ctx=ctx) + scope, rest, _ = _parse_named_scope(tokens, default='project') + miss = _need_work(scope, work) + if miss: + return miss + if not rest: + return arg_error( + f'/mcp {action} [global|project]', ctx=ctx) + name = rest[0] + try: + if action == 'remove': + mgr.remove(name, scope=scope) + else: + mgr.set_enabled(name, action == 'enable', scope=scope) + except KeyError as exc: + return CommandResult( + type=CommandResultType.MESSAGE, content=str(exc)) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'{action} {name} ({scope}).', + ) + + if action == 'add': + rest, fields = _split_kv(tokens) + scope, rest, explicit = _parse_named_scope(rest, default='project') + miss = _need_work(scope, work) + if miss: + return miss + if not rest: + return arg_error( + '/mcp add [global|project] command=|url=', + ctx=ctx, + ) + name = ' '.join(rest) + if 'command' not in fields and 'url' not in fields: + return CommandResult( + type=CommandResultType.MESSAGE, + content='Need command= or url=.', + ) + mgr.add(name, _stdio_fields(fields), scope=scope) + lines = [f'Added {name} ({scope}).'] + if scope == 'project' and not explicit: + lines.append(_PROJECT_MCP_NOT_ON_SETTINGS) + return CommandResult( + type=CommandResultType.MESSAGE, + content='\n'.join(lines), + ) + + if action == 'update': + rest, fields = _split_kv(tokens) + scope, rest, _ = _parse_named_scope(rest, default='project') + miss = _need_work(scope, work) + if miss: + return miss + if not rest or not fields: + return arg_error( + '/mcp update [global|project] command=|url=', + ctx=ctx, + ) + name = ' '.join(rest) + try: + mgr.update(name, _stdio_fields(fields), scope=scope) + except KeyError as exc: + return CommandResult( + type=CommandResultType.MESSAGE, content=str(exc)) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Updated {name} ({scope}).', + ) + + return arg_error( + '/mcp list|get|add|update|json|enable|disable|remove ...', + reason=f'Unknown mcp action {action!r}', + note='Type /mcp for all commands', + ctx=ctx, + ) + + +async def cmd_skills(ctx: CommandContext) -> CommandResult: + arg = (ctx.args or '').strip() + home, work = _home_work(ctx) + from ms_agent.config.skills_manager import SkillsConfigManager + mgr = SkillsConfigManager(global_dir=home) + runtime = getattr(ctx.runtime, '_skill_runtime', None) + if not arg: + listed = ( + _format_skill_rows(runtime.list_all()) if runtime is not None else + _list_skills_from_disk(mgr, work)) + return CommandResult( + type=CommandResultType.MESSAGE, + content=status_then_usage(listed.content, _skill_usage()), + ) + if arg in ('help', '-h', '--help'): + return CommandResult( + type=CommandResultType.MESSAGE, content=_skill_usage()) + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + action = parts[0].lower() + tokens = parts[1:] + + if action == 'list': + if runtime is not None: + return _format_skill_rows(runtime.list_all()) + return _list_skills_from_disk(mgr, work) + + if action in ('enable', 'disable'): + resolved = await _resolve_skill_scope( + tokens, + work, + syntax=f'/skills {action} [global|project]', + ctx=ctx, + mgr=mgr, + action=action) + if isinstance(resolved, CommandResult): + return resolved + scope, rest = resolved + skill_id = ' '.join(rest) + mgr.set_skill_enabled( + skill_id, action == 'enable', scope=scope, project_path=work) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'{action} {skill_id} ({scope}).', + ) + + if action == 'add': + resolved = await _resolve_skill_scope( + tokens, + work, + syntax='/skills add [global|project]', + ctx=ctx, + action='add') + if isinstance(resolved, CommandResult): + return resolved + scope, rest = resolved + try: + names = mgr.import_from_path( + ' '.join(rest), scope=scope, project_path=work) + except FileNotFoundError as exc: + return CommandResult( + type=CommandResultType.MESSAGE, content=str(exc)) + if not names: + return CommandResult( + type=CommandResultType.MESSAGE, + content='No SKILL.md found under that path.', + ) + return CommandResult( + type=CommandResultType.MESSAGE, + content='Imported: ' + ', '.join(names) + f' ({scope}).', + ) + + if action == 'remove': + resolved = await _resolve_skill_scope( + tokens, + work, + syntax='/skills remove [global|project]', + ctx=ctx, + mgr=mgr, + action='remove') + if isinstance(resolved, CommandResult): + return resolved + scope, rest = resolved + skill_id = ' '.join(rest) + try: + dest = mgr.remove_imported( + skill_id, scope=scope, project_path=work) + except (FileNotFoundError, ValueError) as exc: + return CommandResult( + type=CommandResultType.MESSAGE, content=str(exc)) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Removed managed skill {skill_id} ({dest}).', + ) + + return arg_error( + '/skills list|add|enable|disable|remove ...', + reason=f'Unknown skills action {action!r}', + note='Type /skills for all commands', + ctx=ctx, + ) + + +def _managed_skill_scopes(mgr, skill_id: str, work: str | None) -> list[str]: + """Scopes whose live tree contains ``/SKILL.md``.""" + found: list[str] = [] + if (mgr.global_skills_tree() / skill_id / 'SKILL.md').is_file(): + found.append('global') + if work and (mgr.project_skills_tree(work) / skill_id / 'SKILL.md').is_file(): + found.append('project') + return found + + +def _disabled_skill_scopes(mgr, skill_id: str, work: str | None) -> list[str]: + found: list[str] = [] + if skill_id in set(mgr.load_global().get('disabled') or []): + found.append('global') + if work and skill_id in set(mgr.load_project(work).get('disabled') or []): + found.append('project') + return found + + +def _pick_existing_skill_scope(mgr, skill_id: str, work: str | None, + action: str) -> str | None: + """The one scope that already has this skill, ``both``, or None. + + Enable follows the disabled list when one is set, so a global-only + skill that was turned off globally is turned back on there. Disable + and remove follow the managed copy on disk. + """ + files = _managed_skill_scopes(mgr, skill_id, work) + if action == 'enable': + disabled = _disabled_skill_scopes(mgr, skill_id, work) + if len(disabled) == 1: + return disabled[0] + if len(disabled) > 1: + return 'both' + if len(files) == 1: + return files[0] + if len(files) > 1: + return 'both' + return None + + +async def _resolve_skill_scope( + tokens: list[str], + work: str | None, + *, + syntax: str, + ctx: CommandContext, + mgr=None, + action: str | None = None, +) -> tuple[str, list[str]] | CommandResult: + if not tokens: + return arg_error(syntax, ctx=ctx) + scope, rest = parse_optional_scope(tokens) + if not rest: + return arg_error(syntax, ctx=ctx) + if scope is None and mgr is not None and action in ( + 'enable', 'disable', 'remove'): + skill_id = ' '.join(rest) + picked = _pick_existing_skill_scope(mgr, skill_id, work, action) + if picked == 'both': + # Leave both copies where they are. The menu picks which one. + chosen = await pick_scope( + ctx, + syntax=syntax, + header=f'{skill_id} 在两处都有。改哪一处?', + reason=f'{skill_id} exists in both global and project', + note='Pass global or project.', + ) + if isinstance(chosen, CommandResult): + return chosen + scope = chosen + elif picked: + scope = picked + if scope is None and action == 'add': + # A new slash-command skill lands on this machine unless the menu + # (or an explicit scope) places it in this folder. + choices = list(SCOPE_CHOICES) if work else (('global', '这台机器'), ) + chosen = await pick_scope( + ctx, + syntax=syntax, + header='新技能放到哪里?', + choices=choices, + silent_default='global', + ) + if isinstance(chosen, CommandResult): + return chosen + scope = chosen + if scope is None: + scope = 'global' + miss = _need_work(scope, work) + if miss: + return miss + return scope, rest + + +def _format_skill_rows(rows) -> CommandResult: + if not rows: + return CommandResult( + type=CommandResultType.MESSAGE, content='No skills.') + lines = ['Skills:'] + for row in rows: + on = 'on' if row.get('enabled') else 'off' + lines.append( + f' [{on}] {row["skill_id"]} {row.get("name") or ""}'.rstrip()) + return CommandResult( + type=CommandResultType.MESSAGE, content='\n'.join(lines)) + + +def _list_skills_from_disk(mgr, work: str | None) -> CommandResult: + """Scan live trees the same way WebUI does (presence = registered).""" + from ms_agent.skill.loader import SkillLoader + merged = mgr.load_merged(work) + sources = [ + source for source in (merged.get('sources') or []) + if isinstance(source, str) + ] + try: + discovered = SkillLoader().discover_skills(sources) + except Exception: # noqa: BLE001 — listing must not crash the TUI + discovered = {} + disabled = set(merged.get('disabled') or []) + by_id: dict[str, dict] = {} + for desc in discovered.values(): + by_id[desc.skill_id] = { + 'skill_id': desc.skill_id, + 'name': desc.name or '', + 'enabled': desc.skill_id not in disabled, + } + rows = [by_id[sid] for sid in sorted(by_id)] + return _format_skill_rows(rows) + + +def register_resource_commands(router: CommandRouter) -> None: + router.register(CMD_MCP, cmd_mcp) + router.register(CMD_SKILL_MANAGE, cmd_skills) diff --git a/ms_agent/command/builtin/search_cmds.py b/ms_agent/command/builtin/search_cmds.py new file mode 100644 index 000000000..9ebfb0e9a --- /dev/null +++ b/ms_agent/command/builtin/search_cmds.py @@ -0,0 +1,156 @@ +"""Slash command for web-search settings (same settings.json block as WebUI).""" +from __future__ import annotations + +from ms_agent.command.router import CommandRouter +from ms_agent.command.types import (CommandContext, CommandDef, CommandResult, + CommandResultType) +from ms_agent.command.usage import (arg_error, ledger_file, status_then_usage) + +CMD_SEARCH = CommandDef( + name='search', + description='Show or set the web-search engine', + category='config', +) + + +def _usage() -> str: + return ( + 'usage:\n' + ' /search\n' + ' /search list\n' + ' /search engine \n' + ' /search key \n' + ' /search key clear\n' + ' /search enable|disable\n' + f'Saved in {ledger_file("settings.json")}.' + ) + + +def _mgr(): + from ms_agent.config.search_settings import SearchSettingsManager + from ms_agent.project.paths import global_home + return SearchSettingsManager(global_home()) + + +def _engine_notice(engine: str, *, has_key: bool, requires_key: bool) -> str: + from ms_agent.tools.search.availability import unavailable_notice + return unavailable_notice( + engine, has_key=has_key, requires_key=requires_key) or '' + + +def _status_text(mgr) -> str: + from ms_agent.config.search_settings import requires_key + cur = mgr.get() + on = 'on' if cur.enabled else 'off' + key = 'set' if cur.has_key else 'missing' + needed = requires_key(cur.engine) + if not needed: + key = 'not required' + elif cur.supports_keyless and not cur.has_key: + key = 'missing (keyless tier may still work)' + lines = [ + f'Search: {on}', + f'Engine: {cur.engine}', + f'API key: {key}', + ] + notice = _engine_notice( + cur.engine, has_key=cur.has_key, requires_key=needed) + if notice: + lines.append(notice) + lines.extend([ + 'Switch: /search engine Key: /search key ', + 'List engines: /search list', + ]) + return '\n'.join(lines) + + +async def cmd_search(ctx: CommandContext) -> CommandResult: + arg = (ctx.args or '').strip() + mgr = _mgr() + if not arg or arg in ('help', '-h', '--help'): + return CommandResult( + type=CommandResultType.MESSAGE, + content=status_then_usage(_status_text(mgr), _usage()), + ) + + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + action = parts[0].lower() + rest = parts[1:] + + if action == 'list': + lines = ['Search engines:'] + current = mgr.get().engine + for row in mgr.list_engines(): + mark = '*' if row['id'] == current else ' ' + key = 'key=set' if row['has_key'] else ( + 'key=n/a' if not row['requires_key'] else 'key=missing') + extra = ' keyless-ok' if row['supports_keyless'] else '' + notice = _engine_notice( + row['id'], + has_key=row['has_key'], + requires_key=row['requires_key'], + ) + if notice: + extra = f'{extra} {notice}' + lines.append( + f' {mark} {row["id"]} {row["label"]} {key}{extra}') + return CommandResult( + type=CommandResultType.MESSAGE, content='\n'.join(lines)) + + if action in ('enable', 'on'): + mgr.set_enabled(True) + return CommandResult( + type=CommandResultType.MESSAGE, + content='Search enabled.', + ) + if action in ('disable', 'off'): + mgr.set_enabled(False) + return CommandResult( + type=CommandResultType.MESSAGE, + content='Search disabled.', + ) + + if action == 'engine': + if not rest: + return arg_error( + '/search engine ', ctx=ctx) + try: + cur = mgr.set_engine(rest[0]) + except ValueError as exc: + return CommandResult( + type=CommandResultType.MESSAGE, content=str(exc)) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Engine → {cur.engine}.', + ) + + if action == 'key': + if not rest: + return arg_error('/search key |clear', ctx=ctx) + raw = ' '.join(rest) + clear = raw.lower() in ('clear', 'none', '-') + try: + mgr.set_api_key(None if clear else raw) + except ValueError as exc: + return CommandResult( + type=CommandResultType.MESSAGE, content=str(exc)) + verb = 'cleared' if clear else 'saved' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'API key {verb} for {mgr.get().engine}.', + ) + + return arg_error( + '/search engine|key|list|enable|disable ...', + reason=f'Unknown search action {action!r}', + note='Type /search for status and all commands', + ctx=ctx, + ) + + +def register_search_commands(router: CommandRouter) -> None: + router.register(CMD_SEARCH, cmd_search) diff --git a/ms_agent/command/interactive.py b/ms_agent/command/interactive.py index 591bee092..fc0484e3d 100644 --- a/ms_agent/command/interactive.py +++ b/ms_agent/command/interactive.py @@ -79,6 +79,8 @@ async def run_turn( self, messages: Optional[List[Any]] = None, runtime: Any = None, + *, + retry_when=None, ) -> InteractiveTurn: """Read input at ``>>>`` until a real prompt or a quit signal. @@ -126,9 +128,17 @@ async def run_turn( 'persist_context': self._persist_context, 'router': self._router, 'messages': messages if messages is not None else [], + # TUI input_source only — never a blocking menu in + # CLI/tests that drive InteractiveSession via input(). + 'interactive': self._input_source is not None, }, ) - result = await self._router.dispatch(ctx) + result = None + try: + result = await self._router.dispatch(ctx) + except Exception as exc: # noqa: BLE001 — command bugs stay in-loop + self._emit(f'command failed: {type(exc).__name__}: {exc}') + continue if result is None: # Unrecognized command — treat it as a normal prompt. return InteractiveTurn(action='submit', text=query) @@ -139,8 +149,36 @@ async def run_turn( if result.type == CommandResultType.SUBMIT_PROMPT: return InteractiveTurn(action='submit', text=result.content) # MESSAGE / MUTATE_STATE: show output and prompt again. + if result.type == CommandResultType.MUTATE_STATE: + self._sync_model_label(runtime) if result.content: self._emit(result.content) + # A key just saved can build a client. Hand the original sentence + # back instead of waiting for the user to type it again. + if retry_when is not None: + try: + ready = bool(retry_when()) + except Exception: # noqa: BLE001 — a probe must not trap the prompt + ready = False + if ready: + return InteractiveTurn(action='submit', text='') + + def _sync_model_label(self, runtime) -> None: + """Push the live model id into the TUI status bar, when one is attached.""" + source = self._input_source + note = getattr(source, 'note_model', None) + if not callable(note) or runtime is None: + return + llm = getattr(runtime, 'llm', None) + model = str(getattr(llm, 'model', '') or '') if llm is not None else '' + if not model and llm is not None: + config = getattr(llm, 'config', None) + if config is not None: + from omegaconf import OmegaConf + model = str( + OmegaConf.select(config, 'llm.model', default='') or '') + if model: + note(model) def _emit(self, text: str) -> None: if self._event_sink is not None: diff --git a/ms_agent/command/scope.py b/ms_agent/command/scope.py new file mode 100644 index 000000000..c65eec709 --- /dev/null +++ b/ms_agent/command/scope.py @@ -0,0 +1,150 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Scope tokens for slash commands: a default when one exists, else a menu. + +Claude-style: only silent-default a scope when it cannot send the user looking +in the wrong place. Otherwise pick interactively (TUI arrow menu) or print a +focused ``Need:`` line so scripts/tests never block. +""" +from __future__ import annotations + +import inspect +import os +import sys +from typing import Sequence + +from ms_agent.command.types import CommandContext, CommandResult, CommandResultType +from ms_agent.command.usage import arg_error + +SCOPE_TOKENS = frozenset({'global', 'project'}) + +# (scope, label). The TUI arrow menu shows these two rows. +SCOPE_CHOICES: tuple[tuple[str, str], ...] = ( + ('global', '这台机器'), + ('project', '这个文件夹'), +) + + +def parse_optional_scope(tokens: Sequence[str]) -> tuple[str | None, list[str]]: + """Pull a trailing global|project token when a name/path precedes it. + + A lone ``project`` (or ``global``) is the id/path, not a scope — otherwise + ``/skills disable project`` could never target that skill. + """ + rest = list(tokens) + if len(rest) >= 2 and rest[-1] in SCOPE_TOKENS: + return rest[-1], rest[:-1] + return None, rest + + +def work_dir_of(ctx: CommandContext) -> str | None: + """Resolve ``--work-dir`` from whatever the dispatcher passed as runtime. + + Live TUI/CLI slash commands receive ``ms_agent.agent.runtime.Runtime`` + (``runtime.llm.config.output_dir``). Tests often pass a fake agent with + ``runtime.config.output_dir``. Accept either, plus ``extra['work_dir']``. + """ + extra = (ctx.extra or {}).get('work_dir') + if extra: + return str(extra) + runtime = ctx.runtime + if runtime is None: + return None + for holder in (runtime, getattr(runtime, 'llm', None)): + if holder is None: + continue + config = getattr(holder, 'config', None) + work = getattr(config, 'output_dir', None) if config is not None else None + if work: + return str(work) + return None + + +def can_pick_interactively(ctx: CommandContext) -> bool: + """True only for a live TUI prompt on a TTY. + + Router-dispatched tests (and pipes) must not open a blocking menu. + ``InteractiveSession`` sets ``extra['interactive']`` when it has a TUI + input source. Pytest is never interactive even if stdin is a TTY. + """ + extra = ctx.extra or {} + if extra.get('choose_scope'): + return False + if os.environ.get('PYTEST_CURRENT_TEST'): + return False + if not extra.get('interactive'): + return False + try: + return bool(sys.stdin.isatty()) + except Exception: # noqa: BLE001 + return False + + +async def pick_scope( + ctx: CommandContext, + *, + syntax: str, + header: str, + choices: Sequence[tuple[str, str]] | None = None, + note: str = 'Pass global or project — there is no default.', + reason: str = '', + silent_default: str | None = None, +) -> str | CommandResult: + """Return a scope, or a CommandResult (Need: / Cancelled). + + One remaining choice is taken without asking. A live TUI uses the same + arrow menu as permissions (``select_async``). ``silent_default`` is used + only when nobody is there to answer, so a script still writes the new + skill onto this machine. Tests may inject ``extra['choose_scope']``. + """ + rows = list(choices) if choices is not None else list(SCOPE_CHOICES) + if len(rows) == 1: + return rows[0][0] + if not rows: + return arg_error(syntax, reason='No global or project copy found', ctx=ctx) + + labels = [label for _, label in rows] + picked = await _invoke_test_picker(ctx, labels, header) + if picked is None and can_pick_interactively(ctx): + from ms_agent.tui.select import select_async + # First row is 这台机器, so Enter keeps a new skill on this machine. + picked = await select_async(labels, header=header) + if picked is None: + return CommandResult( + type=CommandResultType.MESSAGE, content='Cancelled.') + + scope = _coerce_pick(picked, rows) + if scope is not None: + return scope + if silent_default in ('global', 'project'): + return silent_default + return arg_error(syntax, reason=reason, note=note, ctx=ctx) + + +async def _invoke_test_picker( + ctx: CommandContext, + labels: Sequence[str], + header: str, +): + picker = (ctx.extra or {}).get('choose_scope') + if not callable(picker): + return None + try: + result = picker(labels, header) + if inspect.isawaitable(result): + result = await result + return result + except Exception: # noqa: BLE001 — a test hook must not crash the TUI + return None + + +def _coerce_pick(picked, rows: Sequence[tuple[str, str]]) -> str | None: + if picked is None: + return None + if isinstance(picked, int) and 0 <= picked < len(rows): + return rows[picked][0] + if isinstance(picked, str): + low = picked.strip().lower() + for scope, label in rows: + if low in (scope, label.lower()): + return scope + return None diff --git a/ms_agent/command/skill_bridge.py b/ms_agent/command/skill_bridge.py index f53b9a1f7..5a19c2a6f 100644 --- a/ms_agent/command/skill_bridge.py +++ b/ms_agent/command/skill_bridge.py @@ -25,6 +25,7 @@ from ms_agent.command.router import CommandRouter from ms_agent.command.types import (CommandContext, CommandResult, CommandResultType) +from ms_agent.skill.harness import fill_harness_placeholders if TYPE_CHECKING: from ms_agent.skill.catalog import SkillCatalog @@ -64,6 +65,11 @@ def expand_skill(catalog: 'SkillCatalog', name_or_id: str, return None body = _strip_frontmatter(skill.content) + body = fill_harness_placeholders( + body, + getattr(catalog, '_agent_config', None) + or getattr(catalog, '_config', None), + skill.skill_id) body = body.replace('$ARGUMENTS', args) tail = ( diff --git a/ms_agent/command/usage.py b/ms_agent/command/usage.py new file mode 100644 index 000000000..b7b7e8335 --- /dev/null +++ b/ms_agent/command/usage.py @@ -0,0 +1,83 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Focused usage errors for slash commands. + +Bare ``/cmd`` (or ``/cmd help``) still shows the full sheet. A recognized +subcommand invoked with the wrong shape should say what was missing and +print that one syntax line — not the entire help dump. +""" +from __future__ import annotations + +from ms_agent.command.types import CommandContext, CommandResult, CommandResultType + + +def ledger_dir() -> str: + """Effective global home for this process (honors MS_AGENT_HOME).""" + from ms_agent.project.paths import global_home + return str(global_home()) + + +def ledger_file(rel: str) -> str: + """``{global_home}/{rel}`` at call time (not import time).""" + return f'{ledger_dir()}/{rel}' + + +def status_then_usage(status: str, usage: str) -> str: + """Bare ``/cmd``: a short identity card, then the usage sheet. + + Status stays a handful of lines (or a truncated preview). The sheet is + below so the current value is what the user sees first. + """ + body = (status or '').rstrip() + sheet = (usage or '').strip() + if not body: + return sheet + if not sheet: + return body + return f'{body}\n\n{sheet}' + + +def mask_secrets(text: str) -> str: + """Replace high-confidence secret values, using the convert-upload filter.""" + if not text: + return text + from ms_agent.agent_hub._secrets import redact_text + cleaned, hits = redact_text(text) + return cleaned if hits else text + + +def contains_secret(text: str) -> bool: + """True when ``text`` itself looks like a credential (``sk-…``, and the same).""" + if not text: + return False + from ms_agent.agent_hub._secrets import redact_text + _cleaned, hits = redact_text(text) + return bool(hits) + + +def arg_error( + syntax: str, + *, + reason: str = '', + note: str = '', + got: str = '', + ctx: CommandContext | None = None, +) -> CommandResult: + """Build a short parse/usage error for a known command. + + ``syntax`` is the one line the user should type, e.g. + ``/model catalog remove ``. + """ + if ctx is not None and not got: + got = str(getattr(ctx, 'raw_input', '') or '').strip() + lines: list[str] = [] + if reason: + text = reason.rstrip() + if text[-1:] not in '.!?': + text += '.' + lines.append(text) + lines.append(f'Need: {syntax}') + if got: + lines.append(f'Got: {mask_secrets(got)}') + if note: + lines.append(note) + return CommandResult(type=CommandResultType.MESSAGE, content='\n'.join(lines)) diff --git a/ms_agent/config/mcp_manager.py b/ms_agent/config/mcp_manager.py index 721796a4f..d4112f3f0 100644 --- a/ms_agent/config/mcp_manager.py +++ b/ms_agent/config/mcp_manager.py @@ -125,8 +125,10 @@ def list(self, scope: MCPScope = 'merged') -> Dict[str, Dict[str, Any]]: self._load_scope_raw('project'), source='project') global_servers = self._normalize_scope( self._load_scope_raw('global'), source='global') - project_servers = self._normalize_scope( - self._load_scope_raw('project'), source='project') + project_servers: Dict[str, Dict[str, Any]] = {} + if self.project_root is not None: + project_servers = self._normalize_scope( + self._load_scope_raw('project'), source='project') from ms_agent.config.mcp_schema import merge_mcp_layers return merge_mcp_layers(global_servers, project_servers) diff --git a/ms_agent/config/model_settings.py b/ms_agent/config/model_settings.py index dccc7591e..4b8bc0a0f 100644 --- a/ms_agent/config/model_settings.py +++ b/ms_agent/config/model_settings.py @@ -42,6 +42,21 @@ def resolve_model_settings(data: dict, provider: str, model: str) -> dict: return result +def strip_provider_model_prefix(provider: str | None, model: str | None) -> str: + """Drop a duplicated `` `` prefix from a stored model id. + + A failed space-form persist can leave ``default_model`` as + ``minimax/minimax MiniMax-M2.1``. First-slash split then yields + service=minimax and model=``minimax MiniMax-M2.1``, which is not a + vendor id. + """ + provider = str(provider or '').strip() + model = str(model or '').strip() + if provider and model.lower().startswith(provider.lower() + ' '): + return model[len(provider):].strip() + return model + + class ModelSettingsManager: """CRUD for custom providers/models + default model in settings.json.""" @@ -120,6 +135,46 @@ def remove_provider(self, provider_id: str) -> None: data.pop('default_model', None) self._save_raw(data) + @locked(lambda self, *args, **kwargs: self._path) + def patch_provider( + self, + provider_id: str, + *, + name: Optional[str] = None, + protocol: Optional[str] = None, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + clear_api_key: bool = False, + clear_base_url: bool = False, + ) -> Dict[str, Any]: + """Partial update of a providers. entry (creates it if missing). + + ``api_key=''`` / ``base_url=''`` also clear. Used by TUI ``/model + provider set|key|url`` so a key-only change does not reset protocol. + """ + data = self._load_raw() + providers = data.setdefault('providers', {}) + entry = dict(providers.get(provider_id) or { + 'name': provider_id, + 'protocol': 'openai', + 'models': [], + }) + if name: + entry['name'] = name + if protocol: + entry['protocol'] = protocol + if clear_api_key or api_key == '': + entry.pop('api_key', None) + elif api_key is not None: + entry['api_key'] = api_key + if clear_base_url or base_url == '': + entry.pop('base_url', None) + elif base_url is not None: + entry['base_url'] = base_url + providers[provider_id] = entry + self._save_raw(data) + return entry + @locked(lambda self, *args, **kwargs: self._path) def add_model(self, provider_id: str, model: str) -> None: data = self._load_raw() @@ -131,20 +186,24 @@ def add_model(self, provider_id: str, model: str) -> None: self._save_raw(data) @locked(lambda self, *args, **kwargs: self._path) - def remove_model(self, provider_id: str, model: str) -> None: + def remove_model(self, provider_id: str, model: str) -> bool: + """Remove ``model`` from the provider catalog. True if it was present.""" data = self._load_raw() entry = data.get('providers', {}).get(provider_id) - if entry and model in entry.get('models', []): - entry['models'].remove(model) - llm = data.get('llm') or {} - if (llm.get('provider'), llm.get('model')) == (provider_id, model): - for key in ('api_key', 'base_url', 'protocol'): - if key in llm and key not in entry: - entry[key] = llm[key] - data['llm'] = {} - if data.get('default_model') in (f'{provider_id}/{model}', model): - data.pop('default_model', None) - self._save_raw(data) + models = (entry or {}).get('models') or [] + if entry is None or model not in models: + return False + models.remove(model) + llm = data.get('llm') or {} + if (llm.get('provider'), llm.get('model')) == (provider_id, model): + for key in ('api_key', 'base_url', 'protocol'): + if key in llm and key not in entry: + entry[key] = llm[key] + data['llm'] = {} + if data.get('default_model') in (f'{provider_id}/{model}', model): + data.pop('default_model', None) + self._save_raw(data) + return True # -- default model -- @@ -156,6 +215,17 @@ def get_default_model(self) -> Optional[str]: def set_default_model(self, model: str, provider: Optional[str] = None) -> None: + model = strip_provider_model_prefix(provider, model) data = self._load_raw() data['default_model'] = f'{provider}/{model}' if provider else model + # Same shape WebUI writes: llm.provider + llm.model, so the next + # ConfigResolver pass (TUI or WebUI) sees the switch without a + # project patch. + if provider: + llm = data.get('llm') + if not isinstance(llm, dict): + llm = {} + llm['provider'] = provider + llm['model'] = model + data['llm'] = llm self._save_raw(data) diff --git a/ms_agent/config/resolver.py b/ms_agent/config/resolver.py index ef7912643..589683193 100644 --- a/ms_agent/config/resolver.py +++ b/ms_agent/config/resolver.py @@ -430,7 +430,10 @@ def _settings_to_agent_config(settings: Dict[str, Any]) -> DictConfig: agent_llm = agent_fields.setdefault('llm', {}) if not agent_llm.get('model'): if '/' in default_model: + from ms_agent.config.model_settings import ( + strip_provider_model_prefix) prov, mdl = default_model.split('/', 1) + mdl = strip_provider_model_prefix(prov, mdl) agent_llm.setdefault('service', prov) agent_llm['model'] = mdl else: @@ -454,8 +457,34 @@ def _settings_to_agent_config(settings: Dict[str, Any]) -> DictConfig: # (honored in ToolManager), which lets a higher layer turn a tool off. if 'tools' in settings: agent_fields['tools'] = settings['tools'] + ConfigResolver._fill_provider_catalog(agent_fields, settings) return OmegaConf.create(agent_fields) + @staticmethod + def _fill_provider_catalog(agent_fields: Dict[str, Any], + settings: Dict[str, Any]) -> None: + """Copy providers. credentials onto llm when the llm block only + names a provider (WebUI stores keys under ``providers``, not always + under ``llm.api_key``). Never overwrites an explicit llm value.""" + providers = settings.get('providers') + if not isinstance(providers, dict): + return + agent_llm = agent_fields.get('llm') + if not isinstance(agent_llm, dict): + return + pid = str(agent_llm.get('service') or '') + entry = providers.get(pid) + if not pid or not isinstance(entry, dict): + return + if entry.get('api_key'): + agent_llm.setdefault(f'{pid}_api_key', entry['api_key']) + agent_llm.setdefault('api_key', entry['api_key']) + if entry.get('base_url'): + agent_llm.setdefault(f'{pid}_base_url', entry['base_url']) + agent_llm.setdefault('base_url', entry['base_url']) + if entry.get('protocol'): + agent_llm.setdefault('protocol', entry['protocol']) + @staticmethod def _load_json_safe(path: Path) -> Dict[str, Any]: if not path.exists(): diff --git a/ms_agent/config/search_settings.py b/ms_agent/config/search_settings.py new file mode 100644 index 000000000..72c7e027d --- /dev/null +++ b/ms_agent/config/search_settings.py @@ -0,0 +1,208 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""CRUD for ``settings.json`` ``tools.web_search`` (same block WebUI writes). + +Keys are per-engine (``tavily_api_key``, ``exa_api_key``, …) because that is +what ``WebSearchTool`` reads. Switching engine must not drop another engine's +key. Environment variables are ignored here: this object describes only what +the settings file owns, matching the WebUI search page. +""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +_PROVIDER_META: Dict[str, tuple[str, Optional[str]]] = { + 'tavily': ('Tavily Search', 'tavily_api_key'), + 'exa': ('Exa Search', 'exa_api_key'), + 'serpapi': ('SerpAPI', 'serpapi_api_key'), + 'arxiv': ('arXiv', None), +} +_KEYLESS = frozenset({'tavily', 'arxiv'}) +_LEGACY_EXA_FIELDS = ('exa_api_keys', 'api_key') +_FALLBACK_ORDER = ('tavily', 'exa', 'serpapi', 'arxiv') + +# Tavily is the only web-wide engine that works with no key (keyless tier). +# WebUI persists this block; TUI injects the same one at resolve time so a +# fresh install can search before anyone opens settings. +DEFAULT_WEB_SEARCH_BLOCK = { + 'mcp': False, + 'engine': 'tavily', + 'enabled': True, +} + + +def supported_engine_ids() -> List[str]: + try: + from ms_agent.tools.search.websearch_tool import WebSearchTool + ids = [str(e).lower() for e in WebSearchTool.SUPPORTED_ENGINES] + except Exception: + ids = list(_FALLBACK_ORDER) + known = [i for i in _FALLBACK_ORDER if i in ids] + extra = sorted(i for i in ids if i not in _FALLBACK_ORDER) + return known + extra + + +def default_engine() -> str: + ids = supported_engine_ids() + if 'tavily' in ids: + return 'tavily' + return ids[0] if ids else 'tavily' + + +def key_field_for(engine: str) -> Optional[str]: + return _PROVIDER_META.get(engine, (engine, f'{engine}_api_key'))[1] + + +def key_fields_for(engine: str) -> tuple[str, ...]: + field = key_field_for(engine) + if field is None: + return () + if engine == 'exa': + return (field, *_LEGACY_EXA_FIELDS) + return (field,) + + +def requires_key(engine: str) -> bool: + return key_field_for(engine) is not None + + +def supports_keyless(engine: str) -> bool: + return engine in _KEYLESS + + +@dataclass(frozen=True) +class SearchSettings: + enabled: bool + engine: str + has_key: bool + supports_keyless: bool + + +class SearchSettingsManager: + """Read/write ``tools.web_search`` in settings.json.""" + + def __init__(self, global_dir: str | Path | None = None) -> None: + if global_dir is None: + from ms_agent.project.paths import global_home + self._dir = global_home() + else: + self._dir = Path(os.path.expanduser(str(global_dir))) + self._path = self._dir / 'settings.json' + + def _load_raw(self) -> Dict[str, Any]: + if not self._path.is_file(): + return {} + try: + with open(self._path, encoding='utf-8') as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (json.JSONDecodeError, OSError): + return {} + + def _save_raw(self, data: Dict[str, Any]) -> None: + self._dir.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix('.json.tmp') + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, self._path) + + def _block(self, data: Dict[str, Any]) -> Dict[str, Any]: + tools = data.get('tools') + if not isinstance(tools, dict): + return {} + block = tools.get('web_search') + return dict(block) if isinstance(block, dict) else {} + + def get(self) -> SearchSettings: + block = self._block(self._load_raw()) + engine = str(block.get('engine') or '').lower() or default_engine() + if engine not in supported_engine_ids(): + engine = default_engine() + return SearchSettings( + enabled=bool(block.get('enabled', True)), + engine=engine, + has_key=self._has_key(block, engine), + supports_keyless=supports_keyless(engine), + ) + + def list_engines(self) -> List[Dict[str, Any]]: + block = self._block(self._load_raw()) + rows = [] + for engine in supported_engine_ids(): + label, _ = _PROVIDER_META.get(engine, (engine, None)) + rows.append({ + 'id': engine, + 'label': label, + 'requires_key': requires_key(engine), + 'supports_keyless': supports_keyless(engine), + 'has_key': self._has_key(block, engine), + }) + return rows + + def set_engine(self, engine: str) -> SearchSettings: + engine = engine.strip().lower() + if engine not in supported_engine_ids(): + raise ValueError( + f'Unknown search engine: {engine}. ' + f'Supported: {", ".join(supported_engine_ids())}') + data = self._load_raw() + block = self._ensure_block(data) + block['engine'] = engine + self._save_raw(data) + return self.get() + + def set_enabled(self, enabled: bool) -> SearchSettings: + data = self._load_raw() + block = self._ensure_block(data) + block['enabled'] = bool(enabled) + self._save_raw(data) + return self.get() + + def set_api_key(self, key: str | None, engine: str | None = None) -> SearchSettings: + current = self.get() + engine = (engine or current.engine).strip().lower() + field = key_field_for(engine) + if field is None: + raise ValueError(f'{engine} does not use an API key.') + data = self._load_raw() + block = self._ensure_block(data) + value = (key or '').strip() + if value: + block[field] = value + else: + block.pop(field, None) + if engine == 'exa': + for alias in _LEGACY_EXA_FIELDS: + block.pop(alias, None) + self._save_raw(data) + return self.get() + + def raw_block(self) -> Dict[str, Any]: + return self._block(self._load_raw()) + + def _ensure_block(self, data: Dict[str, Any]) -> Dict[str, Any]: + tools = data.get('tools') + if not isinstance(tools, dict): + tools = {} + data['tools'] = tools + block = tools.get('web_search') + if not isinstance(block, dict): + # Same discriminator WebUI bootstrap writes. + block = {'mcp': False} + tools['web_search'] = block + else: + tools['web_search'] = block + if not block.get('engine'): + block['engine'] = default_engine() + if 'enabled' not in block: + block['enabled'] = True + return block + + @staticmethod + def _has_key(block: Dict[str, Any], engine: str) -> bool: + return any( + str(block.get(field) or '').strip() + for field in key_fields_for(engine)) diff --git a/ms_agent/config/skills_manager.py b/ms_agent/config/skills_manager.py index abe7db635..199070b10 100644 --- a/ms_agent/config/skills_manager.py +++ b/ms_agent/config/skills_manager.py @@ -185,6 +185,98 @@ def set_skill_enabled( data['disabled'] = sorted(disabled) self._write(path, data) + # -- live-tree import (same destination WebUI copies into) -- + + def import_from_path( + self, + source: str, + scope: str = 'global', + project_path: Optional[str] = None, + ) -> List[str]: + """Copy Skill directories into this scope's managed live tree. + + A directory with ``SKILL.md`` is one skill; otherwise each immediate + child that has ``SKILL.md`` is imported. Existence in the live tree is + what the catalog discovers — no ``skills.json`` source row is added. + """ + import shutil + + root = Path(os.path.expanduser(str(source))).resolve() + if not root.is_dir(): + raise FileNotFoundError(f'Not a directory: {root}') + dest_root = ( + self.global_skills_tree() if scope != 'project' else + self.project_skills_tree(project_path or '')) + dest_root.mkdir(parents=True, exist_ok=True) + + def _is_skill(path: Path) -> bool: + return path.is_dir() and (path / 'SKILL.md').is_file() + + dirs = [root] if _is_skill(root) else [ + child for child in sorted(root.iterdir()) if _is_skill(child) + ] + imported: List[str] = [] + for src_dir in dirs: + dest = dest_root / src_dir.name + if dest.resolve() == src_dir.resolve(): + imported.append(src_dir.name) + continue + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src_dir, dest) + imported.append(src_dir.name) + return imported + + def remove_imported( + self, + skill_id: str, + scope: str = 'global', + project_path: Optional[str] = None, + ) -> Path: + """Delete a managed live-tree skill (same as WebUI managed origin). + + Only removes ``//``. Auto-discovered + ``.agents/skills`` trees and other explicit sources are left alone — + disable those instead. Also drops this id from ``disabled`` and any + ``skills.json`` source that pointed at the deleted directory. + """ + import shutil + + skill_id = (skill_id or '').strip() + if (not skill_id or skill_id in ('.', '..') or '/' in skill_id + or '\\' in skill_id): + raise ValueError(f'Invalid skill id: {skill_id!r}') + dest_root = ( + self.global_skills_tree() if scope != 'project' else + self.project_skills_tree(project_path or '')) + dest = (dest_root / skill_id).resolve() + root = dest_root.resolve() + try: + dest.relative_to(root) + except ValueError as exc: + raise ValueError(f'Invalid skill id: {skill_id!r}') from exc + if dest == root: + raise ValueError(f'Invalid skill id: {skill_id!r}') + if not dest.is_dir() or not (dest / 'SKILL.md').is_file(): + raise FileNotFoundError( + f'{skill_id} is not a managed skill in {scope} scope; ' + 'disable it instead.') + shutil.rmtree(dest) + data = self._read(self._resolve_path(scope, project_path)) + if skill_id in (data.get('disabled') or []): + self.set_skill_enabled( + skill_id, True, scope=scope, project_path=project_path) + dest_str = str(dest) + for source in list(self.list_explicit_sources( + scope, project_path=project_path)): + try: + if str(Path(source).expanduser().resolve()) == dest_str: + self.remove_source( + source, scope=scope, project_path=project_path) + except OSError: + continue + return dest + # -- sources -- def add_source( diff --git a/ms_agent/llm/credentials.py b/ms_agent/llm/credentials.py index 76a0856dc..6cbb39628 100644 --- a/ms_agent/llm/credentials.py +++ b/ms_agent/llm/credentials.py @@ -12,6 +12,7 @@ from __future__ import annotations import os +import re from omegaconf import DictConfig from typing import Optional @@ -30,6 +31,56 @@ def _cfg_get(config: DictConfig, field: str) -> Optional[str]: return value or None +def is_missing_api_key_error(exc: BaseException) -> bool: + """True when provider routing failed because no key is configured.""" + return isinstance(exc, ValueError) and 'No API key found' in str(exc) + + +def client_rejected_missing_key(exc: BaseException) -> bool: + """True when a client cannot be built because no key is configured. + + The provider router raises ``ValueError``. The legacy ModelScope and + OpenAI constructors reach ``openai.OpenAI`` with an empty key, which + raises ``OpenAIError`` instead. + """ + if is_missing_api_key_error(exc): + return True + return type(exc).__name__ == 'OpenAIError' and 'api_key' in str(exc).lower() + + +def missing_api_key_error(config) -> ValueError: + """The error a turn raises when the live client was dropped for no key.""" + from omegaconf import OmegaConf + service = str(OmegaConf.select(config, 'llm.service', default='') or '') + if not service: + return ValueError( + 'No API key found for provider. Set config.llm.api_key.') + return ValueError( + f'No API key found for provider "{service}". ' + f'Set config.llm.{service}_api_key.') + + +_PROVIDER_IN_MISSING_KEY = re.compile(r'provider "([^"]+)"') + +#: Shown when a sentence parked for lack of a client is sent after a key exists. +PENDING_TURN_RESUME = '继续发送未完成的消息。' + + +def missing_api_key_setup_text(exc: BaseException, service: str = '') -> str: + """One command for the provider that is missing a key. + + ``service`` is the live ``llm.service``. The exception text is only a + fallback when that name is not on the config yet. + """ + name = (service or '').strip() + if not name: + match = _PROVIDER_IN_MISSING_KEY.search(str(exc)) + name = match.group(1) if match else '' + if not name: + return '还没有密钥。\n\n /model provider key <密钥>' + return f'还没有 {name} 的密钥。\n\n /model provider key {name} <密钥>' + + class CredentialResolver: @staticmethod diff --git a/ms_agent/llm/model_discovery.py b/ms_agent/llm/model_discovery.py new file mode 100644 index 000000000..3dd480a53 --- /dev/null +++ b/ms_agent/llm/model_discovery.py @@ -0,0 +1,263 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Discover chat model ids from a provider's standard /models endpoint. + +Best-effort: missing key, network error, non-2xx, or a non-standard payload +all degrade to ``[]``. Image/video/embedding/audio ids are filtered out for +TUI listing — the endpoint itself usually returns every product on the key. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Iterable, List, Optional, Sequence, Tuple + +import httpx + +# Name heuristics only: OpenAI-compatible /models rarely carries a modality +# field. Vision-understanding ids (qwen-vl, gpt-4o, glm-4v) are kept. +_NON_CHAT_RE = re.compile( + r'(embedding|rerank|moderation|whisper|transcribe|' + r'(?:^|[-_./])(?:tts|asr|video|speech)(?:$|[-_./])|' + r'wanx|wan[-_.]?2|(?:^|[-_./])wan\d|' + r't2i|t2v|i2v|i2i|ti2v|' + r'dall-?e|dalle|gpt-image|flux|stable-?diff|imagen|' + r'image-gen|qwen-image|' + r'kling|sora|veo[-_.]|cogview|cogvideox|' + r'sambert|paraformer|cosyvoice|qwen-audio|qwen2-audio|' + r'realtime)', + re.IGNORECASE, +) + + +def parse_model_ids(payload: object) -> List[str]: + """Extract ids from a standard OpenAI/Anthropic ``{"data":[{"id":...}]}``.""" + ids: List[str] = [] + if isinstance(payload, dict): + data = payload.get('data') + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + mid = item.get('id') + if isinstance(mid, str) and mid: + ids.append(mid) + return sorted(set(ids)) + + +def is_non_chat_model(model_id: str) -> bool: + return bool(model_id) and bool(_NON_CHAT_RE.search(model_id)) + + +def filter_chat_model_ids(ids: Sequence[str]) -> Tuple[List[str], int]: + """Return (chat-oriented ids, number dropped).""" + kept: List[str] = [] + dropped = 0 + for mid in ids: + if is_non_chat_model(mid): + dropped += 1 + else: + kept.append(mid) + return kept, dropped + + +def wire_protocol(protocol_or_transport: str) -> str: + p = (protocol_or_transport or '').lower() + if p in ('anthropic', 'anthropic_messages'): + return 'anthropic' + return 'openai' + + +#: Shown under a model picker. Non-chat ids may remain; the line says so. +LIST_FOOTER = ( + '此列表供选择对话模型。其中可能包含生图、视频、语音或向量模型,' + '这些模型不支持当前对话。') + +#: Shown when a turn fails because the selected model cannot chat. +CHAT_UNSUPPORTED = '该模型不支持对话。请重新选择对话模型。' + +_CHAT_UNSUPPORTED_MARKERS = ( + 'does not support chat', + 'not a chat model', + 'unsupported for chat', + 'only supports embeddings', + 'image generation model', +) + +#: Shown when this address rejects the model id. A typo and a non-chat id +#: look the same here, so this is not the "cannot chat" sentence. +MODEL_ID_REJECTED = '这个模型名在当前地址上不可用。请换一个对话模型。' + +_MODEL_ID_REJECTED_MARKERS = ( + 'invalid model id', + 'model_not_supported', + 'has no provider supported', +) + +# "Model does not exist" and "The model `x` does not exist", not a bare +# "does not exist". +_MODEL_GONE_RE = re.compile( + r'model\b.{0,60}does not exist', + re.IGNORECASE | re.DOTALL, +) + + +@dataclass +class CatalogProbe: + """Result of GET {base}/models. ``status`` is None when nothing answered. + + ``outcome`` is ``ok`` (ids parsed), ``auth`` (401/403), ``unreachable`` + (connection or timeout), or ``no_catalog`` (404, empty, or unknown body). + """ + + outcome: str + status: Optional[int] + ids: List[str] + + +def probe_model_catalog(base_url: str, protocol: str, + api_key: str) -> CatalogProbe: + """GET the provider model list and keep the HTTP outcome. + + A missing catalog is not "the model id is absent". Callers that only + need ids should use :func:`fetch_model_ids`. + """ + if not base_url: + return CatalogProbe('no_catalog', None, []) + base = base_url.rstrip('/') + if wire_protocol(protocol) == 'anthropic': + if not base.endswith('/v1'): + base = f'{base}/v1' + url = f'{base}/models' + headers = {'anthropic-version': '2023-06-01'} + if api_key: + headers['x-api-key'] = api_key + else: + url = f'{base}/models' + headers = {} + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + try: + with httpx.Client(timeout=8) as client: + resp = client.get(url, headers=headers) + except (httpx.TimeoutException, httpx.TransportError): + return CatalogProbe('unreachable', None, []) + except Exception: + return CatalogProbe('unreachable', None, []) + code = resp.status_code + if code in (401, 403): + return CatalogProbe('auth', code, []) + if code // 100 != 2: + return CatalogProbe('no_catalog', code, []) + try: + ids = parse_model_ids(resp.json()) + except Exception: + return CatalogProbe('no_catalog', code, []) + if not ids: + return CatalogProbe('no_catalog', code, []) + return CatalogProbe('ok', code, ids) + + +def fetch_model_ids(base_url: str, protocol: str, api_key: str) -> List[str]: + """Return available model ids, or [] when the catalog cannot be read.""" + probe = probe_model_catalog(base_url, protocol, api_key) + if probe.outcome != 'ok': + return [] + return probe.ids + + +def nearby_model_ids(wanted: str, ids: Sequence[str], n: int = 5) -> List[str]: + """A few catalog names close to ``wanted``, else the first ``n`` ids.""" + import difflib + pool = list(ids) + hits = difflib.get_close_matches(wanted, pool, n=n, cutoff=0.4) + if hits: + return hits + return pool[:n] + + +def chat_failed_because_unsupported(model_id: str, error: str = '') -> bool: + """Whether a failed turn should use :data:`CHAT_UNSUPPORTED`.""" + if is_non_chat_model(model_id or ''): + return True + low = (error or '').lower() + return any(marker in low for marker in _CHAT_UNSUPPORTED_MARKERS) + + +def model_id_rejected(error: str = '') -> bool: + """Whether this address refused the model id. + + ``401`` / ``403`` stay out: those are credentials, not the model name. + """ + low = (error or '').lower() + if any(marker in low for marker in _MODEL_ID_REJECTED_MARKERS): + return True + return bool(_MODEL_GONE_RE.search(error or '')) + + +def format_id_list(ids: Iterable[str], *, limit: int = 40) -> str: + shown = list(ids) + extra = 0 + if limit and len(shown) > limit: + extra = len(shown) - limit + shown = shown[:limit] + text = ', '.join(shown) if shown else '(none)' + if extra: + text += f' … +{extra} more' + return text + + +def group_model_ids( + ids: Sequence[str] +) -> Tuple[List[Tuple[str, List[str]]], List[str]]: + """Bucket ids the way the WebUI model picker does. + + ``owner/rest`` ids become a named group. Prefixless ids that share a + leading letter run (qwen-plus, qwen3.8-flash) share a family group when + there are at least two. Leftover singletons stay ungrouped. + """ + ungrouped: List[str] = [] + slash_groups: dict[str, List[str]] = {} + for mid in ids: + cut = mid.find('/') + owner = mid[:cut] if cut > 0 else '' + rest = mid[cut + 1:] if cut > 0 else '' + if owner and rest: + slash_groups.setdefault(owner, []).append(mid) + else: + ungrouped.append(mid) + + families: dict[str, List[str]] = {} + singles: List[str] = [] + by_family: dict[str, List[str]] = {} + for mid in ungrouped: + match = re.match(r'[A-Za-z]+', mid) + key = match.group(0) if match else mid + by_family.setdefault(key, []).append(mid) + for key, members in by_family.items(): + if len(members) >= 2: + families[key] = members + else: + singles.extend(members) + + grouped: List[Tuple[str, List[str]]] = [] + for key in sorted(families): + grouped.append((key, families[key])) + for owner in slash_groups: + grouped.append((f'{owner}/', slash_groups[owner])) + return grouped, singles + + +def format_live_model_lines(ids: Sequence[str], + *, + indent: str = ' ') -> List[str]: + """One model id per line; family / owner headers for scannable TUI output.""" + if not ids: + return [f'{indent}(none)'] + grouped, singles = group_model_ids(ids) + lines: List[str] = [] + for title, members in grouped: + lines.append(f'{indent}{title} ({len(members)})') + for mid in members: + lines.append(f'{indent} {mid}') + for mid in singles: + lines.append(f'{indent}{mid}') + return lines diff --git a/ms_agent/llm/runtime_token.py b/ms_agent/llm/runtime_token.py new file mode 100644 index 000000000..121fbd7bb --- /dev/null +++ b/ms_agent/llm/runtime_token.py @@ -0,0 +1,24 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Fingerprint of the LLM settings a live client was built from.""" +from __future__ import annotations + +import json + +from omegaconf import OmegaConf + + +def llm_runtime_token(config) -> str: + """Stable token for ``llm`` plus ``generation_config``. + + A change means the next turn should rebuild the client in place. The + token stays in memory; it is not written to disk. + """ + llm = OmegaConf.select(config, 'llm', default=None) + gen = OmegaConf.select(config, 'generation_config', default=None) + payload = { + 'llm': + OmegaConf.to_container(llm, resolve=True) if llm is not None else None, + 'generation': + OmegaConf.to_container(gen, resolve=True) if gen is not None else None, + } + return json.dumps(payload, sort_keys=True, default=str) diff --git a/ms_agent/mcp/runtime.py b/ms_agent/mcp/runtime.py index b1aabfc3b..29561a90c 100644 --- a/ms_agent/mcp/runtime.py +++ b/ms_agent/mcp/runtime.py @@ -11,8 +11,8 @@ from ms_agent.config.mcp_schema import (ResolvedMCPConfig, connection_params_for_client) -from ms_agent.tools.mcp_client import MCPClient -from ms_agent.utils import enhance_error, get_logger +from ms_agent.tools.mcp_client import MCP_PROBE_TIMEOUT, MCPClient +from ms_agent.utils import get_logger if TYPE_CHECKING: from ms_agent.tools.tool_manager import ToolManager @@ -60,6 +60,16 @@ def _utc_now() -> str: return datetime.now(timezone.utc).isoformat() +def _exception_group_members(exc: BaseException): + """Nested exceptions of an ExceptionGroup, on 3.11 and the 3.10 backport.""" + if type(exc).__name__ not in ('ExceptionGroup', 'BaseExceptionGroup'): + return None + members = getattr(exc, 'exceptions', None) + if isinstance(members, tuple): + return members + return None + + def classify_mcp_failure(exc: BaseException) -> MCPFailureKind: """Classify transport failures for degraded policy. @@ -68,12 +78,23 @@ def classify_mcp_failure(exc: BaseException) -> MCPFailureKind: after ``DEGRADED_FAILURE_THRESHOLD`` consecutive failures. - ``none``: business / argument errors — do not change ``status``. """ + members = _exception_group_members(exc) + if members is not None: + kinds = [classify_mcp_failure(item) for item in members] + if 'hard' in kinds: + return 'hard' + if 'transient' in kinds: + return 'transient' + return 'none' if isinstance(exc, asyncio.TimeoutError): return 'transient' if isinstance(exc, (BrokenPipeError, ConnectionResetError)): return 'hard' if isinstance(exc, ConnectionError): return 'hard' + type_name = type(exc).__name__.lower() + if type_name in ('closedresourceerror', 'brokenresourceerror'): + return 'hard' msg = str(exc).lower() hard_markers = ( 'connection closed', @@ -105,6 +126,38 @@ def is_connection_error(exc: BaseException) -> bool: return classify_mcp_failure(exc) in ('transient', 'hard') +def short_connect_reason(exc: BaseException, timeout: float) -> str: + """One short clause for a connect that did not succeed.""" + if isinstance(exc, TimeoutError): + label = str(int(timeout)) if timeout >= 1 else f'{timeout:g}' + return f'{label}秒内没有连上' + text = ' '.join(str(exc).split()) + if not text: + text = type(exc).__name__ + if len(text) > 80: + text = text[:79] + '…' + return text + + +def mcp_unreachable_notice(name: str, reason: str) -> str: + """The one line shown when a server is left out of this turn.""" + return f'MCP 服务器 {name} 连不上({reason}),本轮先不用。' + + +def degraded_user_notice( + server_name: str, + exc: BaseException | None = None, + message: str = '', +) -> str: + """One sentence when a server has just become unusable. + + A second failure uses a different sentence, sent from the live-config + sync, not from here. + """ + del exc, message + return f'MCP 服务器 {server_name} 连接断开,本轮对话无法使用。自动尝试重连中。' + + class MCPRuntime: """Configuration-driven MCP lifecycle and ToolManager sync.""" @@ -157,10 +210,10 @@ def _register_from_config(self, config: ResolvedMCPConfig) -> None: ) async def start(self) -> None: - """Connect all enabled servers (idempotent).""" + """Connect enabled servers together. Each one gives up after 6s.""" async with self._sync_lock: self._started = True - failures: list[BaseException] = [] + names: list[str] = [] for name, state in self._states.items(): if not state.enabled: state.status = 'disabled' @@ -169,14 +222,8 @@ async def start(self) -> None: if state.status != 'connected': state.status = 'connected' continue - try: - await self._connect_server(name, state) - except Exception as exc: - if self._connect_policy == 'fail_fast': - raise - failures.append(exc) - if failures and self._connect_policy == 'fail_fast': - raise failures[0] + names.append(name) + await self._probe_servers(names) async def stop(self) -> None: """Disconnect all servers when this runtime owns the client.""" @@ -193,22 +240,60 @@ async def stop(self) -> None: state.tool_count = 0 state.connected_at = None + async def _probe_servers(self, names: list[str]) -> None: + """Connect these servers at once. One slow dial must not stall the rest.""" + pairs = [] + for name in names: + state = self._states.get(name) + if state is None or not state.enabled: + continue + pairs.append((name, state)) + if not pairs: + return + await asyncio.gather(*(self._connect_server(name, state) + for name, state in pairs)) + async def _connect_server(self, name: str, state: MCPServerState) -> None: state.status = 'connecting' state.last_error = None + timeout = MCP_PROBE_TIMEOUT try: - await self._client.connect_single_server( - name, connection_params_for_client(state.config)) + await asyncio.wait_for( + self._client.connect_single_server( + name, + connection_params_for_client(state.config), + startup_timeout=timeout, + ), + timeout=timeout, + ) state.status = 'connected' state.connected_at = _utc_now() state.last_success_at = state.connected_at state.consecutive_failures = 0 await self._refresh_cached_tools(name, state) except Exception as exc: - new_exc = enhance_error(exc, f'Connect `{name}` failed, details:') - await self._record_connect_failure(name, str(new_exc)) + reason = short_connect_reason(exc, timeout) + logger.warning('MCP server %s unreachable: %s', name, exc) + await self._record_connect_failure(name, reason) + await self._drop_unfinished_connect(name) if self._connect_policy == 'fail_fast': - raise new_exc from exc + raise + + async def _drop_unfinished_connect(self, name: str) -> None: + """Stop a dial that the 6s cap abandoned, without the graceful wait.""" + disconnect = getattr(self._client, 'disconnect_server', None) + if disconnect is None: + return + try: + await disconnect(name, graceful=False) + except TypeError: + try: + await disconnect(name) + except Exception: + logger.debug( + 'stop unfinished MCP connect %s', name, exc_info=True) + except Exception: + logger.debug('stop unfinished MCP connect %s', name, exc_info=True) async def _refresh_cached_tools( self, @@ -221,7 +306,9 @@ async def _refresh_cached_tools( state.tool_count = len(state.cached_tools) state.last_success_at = _utc_now() except Exception as exc: - await self.record_failure(name, 'list_tools', str(exc), exc=exc) + # Already inside the connect lock. record_failure would take it + # again and deadlock when several servers list tools together. + self._apply_failure_state(name, 'list_tools', str(exc), exc=exc) # ── enable / disable ─────────────────────────────────────────────── @@ -331,17 +418,26 @@ async def apply_config(self, await self._client.disconnect_server(name) state.status = 'registered' + to_probe: list[str] = [] for name, state in self._states.items(): if not state.enabled: continue + # A degraded server may still hold a dead session. Drop it + # so this apply reconnects instead of keeping the stale one. + if (state.status == 'degraded' + and self._client.is_connected(name)): + await self._client.disconnect_server(name) + state.status = 'registered' if not self._client.is_connected(name): - try: - await self._connect_server(name, state) - except Exception: - if self._connect_policy == 'fail_fast': - raise + # Probed already for this config. The next message must + # not pay the timeout again; a config edit clears it. + if (state.status == 'error' and name not in added + and name not in changed): + continue + to_probe.append(name) elif name in changed: await self._refresh_cached_tools(name, state) + await self._probe_servers(to_probe) await self._sync_tools_unlocked() return list(self._states.values()) @@ -392,12 +488,14 @@ async def record_failure( *, tool_name: str | None = None, exc: BaseException | None = None, - ) -> None: + ) -> bool: + """Record one failure. Return True only when status just became degraded.""" async with self._sync_lock: degraded = self._apply_failure_state( name, phase, message, tool_name=tool_name, exc=exc) if degraded: await self._sync_tools_unlocked() + return degraded def _apply_failure_state( self, diff --git a/ms_agent/memory/default_memory.py b/ms_agent/memory/default_memory.py index d32b02ad8..64ef21b4a 100644 --- a/ms_agent/memory/default_memory.py +++ b/ms_agent/memory/default_memory.py @@ -741,7 +741,12 @@ def sanitize_database_name(ori_name: str, mem0_config['embedder'] = embedder if llm: mem0_config['llm'] = llm - logger.info(f'Memory config: {mem0_config}') + logger.info( + 'Memory config ready: vector=%s embedder=%s llm=%s', + vector_store.get('provider') if vector_store else None, + embedder.get('provider') if isinstance(embedder, dict) else None, + llm.get('provider') if isinstance(llm, dict) else None, + ) # Prompt content is too long, default logging reduces readability custom_fact_extraction_prompt = getattr( self.config.memory.default_memory, 'fact_retrieval_prompt', diff --git a/ms_agent/memory/unified/backends/file_based.py b/ms_agent/memory/unified/backends/file_based.py index 81f7d2fa3..22a18fc59 100644 --- a/ms_agent/memory/unified/backends/file_based.py +++ b/ms_agent/memory/unified/backends/file_based.py @@ -250,16 +250,27 @@ async def handle_tool_call( safe, reason = scan_content(content) if not safe: return f'添加失败(安全检查): {reason}' - ok = self._file_storage._add_entry(content) - result = '已记住' if ok else '添加失败(可能超出字符预算)' + try: + ok = self._file_storage._add_entry(content) + except OSError: + return self._file_storage.user_notice or '' + notice = self._file_storage.user_notice + result = '已记住' if ok else (notice or '添加失败') elif action == 'replace': if not new_content: result = 'replace 操作需要 new_content 参数' else: - ok = self._file_storage.replace_entry(content, new_content) - result = '已更新' if ok else '更新失败(未找到旧内容或超出字符预算)' + try: + ok = self._file_storage.replace_entry(content, new_content) + except OSError: + return self._file_storage.user_notice or '' + notice = self._file_storage.user_notice + result = '已更新' if ok else (notice or '更新失败') elif action == 'remove': - ok = self._file_storage.remove_entry(content) + try: + ok = self._file_storage.remove_entry(content) + except OSError: + return self._file_storage.user_notice or '' result = '已删除' if ok else '删除失败' else: result = f'未知操作: {action}' diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py index a5c47aa16..78df13053 100644 --- a/ms_agent/memory/unified/orchestrator.py +++ b/ms_agent/memory/unified/orchestrator.py @@ -143,6 +143,7 @@ def __init__(self, config: Any) -> None: self._closed = False # Background ingestion bookkeeping (see module docstring). self._pending: Set[asyncio.Task] = set() + self.user_notice: Optional[str] = None # Hashes a scheduled ingest has claimed but not yet written. The ledger # may not advance past them from anywhere else (see mark_ingested). self._inflight: Set[str] = set() @@ -293,6 +294,7 @@ async def _ingest(self, msg_dicts: List[Dict[str, Any]], delta = self._ledger_delta(msg_dicts) if not delta: self._set_status('ok', count=0) + self.user_notice = None return 0 self._set_status('running') result = await backend.on_messages(delta, **kwargs) @@ -302,13 +304,23 @@ async def _ingest(self, msg_dicts: List[Dict[str, Any]], self._ledger_mark(delta) count = result if isinstance(result, int) else len(delta) self._set_status('ok', count=count) + self.user_notice = None return count except asyncio.CancelledError: self._set_status('error', error='cancelled') raise + except OSError as exc: + from ms_agent.memory.unified.user_notice import disk_notice + self.user_notice = disk_notice(exc) + logger.error(self.user_notice) + self._set_status('error', error='disk') + return 0 except Exception as e: # noqa: BLE001 - reported via status - logger.error(f'[orchestrator] memory ingest failed, nothing was ' - f'persisted for this turn: {type(e).__name__}: {e}') + # Background extract/backend failures retry next turn. The user + # has nothing to do with the file, so this stays off the screen. + logger.error( + '[orchestrator] memory ingest failed, nothing was persisted ' + 'for this turn: %s: %s', type(e).__name__, e) self._set_status('error', error=f'{type(e).__name__}: {e}') return 0 finally: @@ -339,9 +351,11 @@ async def flush_pending(self, timeout: float = 15.0) -> None: return done, still = await asyncio.wait(pending, timeout=timeout) if still: - logger.warning( - f'[orchestrator] {len(still)} memory ingest(s) still running ' - f'after {timeout}s flush timeout') + from ms_agent.memory.unified.user_notice import QUIT_WHILE_WRITING + self.user_notice = QUIT_WHILE_WRITING + logger.debug( + '[orchestrator] %s memory ingest(s) still running after %ss ' + 'flush timeout', len(still), timeout) @property def ingest_status(self) -> Dict[str, Any]: diff --git a/ms_agent/memory/unified/storage/file_storage.py b/ms_agent/memory/unified/storage/file_storage.py index 3cad23dad..b80ddb892 100644 --- a/ms_agent/memory/unified/storage/file_storage.py +++ b/ms_agent/memory/unified/storage/file_storage.py @@ -35,6 +35,7 @@ def __init__(self, config: MemoryConfig): self.memory_path = self.base_dir / config.memory_path self.char_limit = config.char_limit self.security_scan = config.security_scan + self.user_notice: Optional[str] = None self._content_cache: Optional[str] = None # (mtime_ns, size) of the file the cache was read from. External # writers exist (the WebUI memory editor, hand edits) and MEMORY.md @@ -95,19 +96,23 @@ async def clear(self) -> bool: # ------------------------------------------------------------------ def _add_entry(self, content: str) -> bool: + self.user_notice = None current = self._read() lines = [line for line in current.splitlines() if line.strip()] deduped = list(dict.fromkeys(lines + [content.strip()])) new_content = '\n'.join(deduped) + '\n' if len(new_content) > self.char_limit: + from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE + self.user_notice = CHAR_LIMIT_NOTICE logger.warning( - f'[file_storage] MEMORY.md would exceed char limit ' - f'({len(new_content)} > {self.char_limit}), skipping add') + '[file_storage] MEMORY.md would exceed char limit ' + '(%s > %s), skipping add', len(new_content), self.char_limit) return False self._write(new_content) return True def replace_entry(self, old_content: str, new_content: str) -> bool: + self.user_notice = None if self.security_scan: safe, reason = scan_content(new_content) if not safe: @@ -119,12 +124,15 @@ def replace_entry(self, old_content: str, new_content: str) -> bool: return False updated = current.replace(old_content.strip(), new_content.strip(), 1) if len(updated) > self.char_limit: + from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE + self.user_notice = CHAR_LIMIT_NOTICE logger.warning('[file_storage] Replace would exceed char limit') return False self._write(updated) return True def remove_entry(self, content: str) -> bool: + self.user_notice = None current = self._read() target = content.strip() lines = current.splitlines() @@ -144,8 +152,10 @@ def full_replace(self, content: str) -> bool: f'[file_storage] Full replace blocked: {reason}') return False if len(content) > self.char_limit: + from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE content = content[:self.char_limit] - logger.warning('[file_storage] Truncated to char limit') + self.user_notice = CHAR_LIMIT_NOTICE + logger.error(CHAR_LIMIT_NOTICE) self._write(content) return True @@ -187,7 +197,14 @@ def _read(self) -> str: return content def _write(self, content: str) -> None: - atomic_write_text(self.memory_path, content) + try: + atomic_write_text(self.memory_path, content) + except OSError as exc: + from ms_agent.memory.unified.user_notice import disk_notice + self.user_notice = disk_notice(exc) + raise + else: + self.user_notice = None self._content_cache = content try: st = self.memory_path.stat() diff --git a/ms_agent/memory/unified/user_notice.py b/ms_agent/memory/unified/user_notice.py new file mode 100644 index 000000000..1d5b21876 --- /dev/null +++ b/ms_agent/memory/unified/user_notice.py @@ -0,0 +1,26 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""One-line notices for memory writes the user can act on. + +TUI's default log level is ERROR, so a warning in the log is invisible. +These sentences are what the session shows instead. +""" +import errno + +CHAR_LIMIT_NOTICE = '记忆写不进去:已超过字数上限。请删短 MEMORY.md 后再试。' +DISK_NOTICE = '记忆写不进去:磁盘或权限不允许写入。请检查记忆目录的权限和剩余空间。' +MISSING_DIR_NOTICE = '记忆目录不在了。请确认目录还在,再试一次。' +PERMISSION_NOTICE = '记忆目录没有写入权限。' +NO_SPACE_NOTICE = '磁盘空间不足,记忆写不进去。' +QUIT_WHILE_WRITING = '记忆还在写入,这次退出可能没保存完。请重新打开会话确认 MEMORY.md。' + + +def disk_notice(exc: BaseException) -> str: + """Pick the sentence that matches why the write was rejected.""" + err = getattr(exc, 'errno', None) + if isinstance(exc, FileNotFoundError) or err == errno.ENOENT: + return MISSING_DIR_NOTICE + if isinstance(exc, PermissionError) or err in (errno.EACCES, errno.EPERM): + return PERMISSION_NOTICE + if err == errno.ENOSPC: + return NO_SPACE_NOTICE + return DISK_NOTICE diff --git a/ms_agent/permission/memory.py b/ms_agent/permission/memory.py index 8024bd346..e589cb947 100644 --- a/ms_agent/permission/memory.py +++ b/ms_agent/permission/memory.py @@ -1,8 +1,8 @@ """PermissionMemory: persist user ``allow_always`` decisions across sessions. Two storage scopes: - - Project: ``.ms_agent/permission_memory.json`` - - Global: ``~/.ms_agent/permission_memory.json`` + - Project: ``/.ms_agent/permission_memory.json`` + - Global: ``/permission_memory.json`` (default ``~/.ms_agent``) Session-level memory (``allow_session``) lives only in-process. """ @@ -45,8 +45,8 @@ def __init__( if global_path is not None: self._global_file = Path(global_path) else: - self._global_file = Path.home( - ) / '.ms_agent' / 'permission_memory.json' + from ms_agent.project.paths import global_home + self._global_file = global_home() / 'permission_memory.json' self._project_entries: list[MemoryEntry] = [] self._global_entries: list[MemoryEntry] = [] diff --git a/ms_agent/personalization/__init__.py b/ms_agent/personalization/__init__.py index f13fdaa4e..88148c7d5 100644 --- a/ms_agent/personalization/__init__.py +++ b/ms_agent/personalization/__init__.py @@ -1,4 +1,5 @@ from ms_agent.personalization.injector import PersonalizationInjector +from ms_agent.personalization.memory_apply import apply_project_memory from ms_agent.personalization.profile import ProfileManager from ms_agent.personalization.settings import PersonalizationSettings from ms_agent.personalization.types import PersonalizationConfig @@ -8,4 +9,5 @@ 'PersonalizationInjector', 'PersonalizationSettings', 'ProfileManager', + 'apply_project_memory', ] diff --git a/ms_agent/personalization/memory_apply.py b/ms_agent/personalization/memory_apply.py new file mode 100644 index 000000000..d89afd949 --- /dev/null +++ b/ms_agent/personalization/memory_apply.py @@ -0,0 +1,51 @@ +"""Wire a project's memory toggle to ``memory.unified_memory``. + +Same shape GitLab WebUI ``_apply_webui_memory`` writes so TUI and WebUI share +MEMORY.md under ``/.ms_agent/memory/``. Vector/mem0 stays WebUI-owned: +TUI persists the backend flag but does not silently fall back to file. +""" +from __future__ import annotations + +from typing import Any + +from omegaconf import OmegaConf, open_dict + + +def _drop_memory(config) -> None: + if OmegaConf.select(config, 'memory', default=None) is None: + return + with open_dict(config): + if 'memory' in config: + del config.memory + + +def apply_project_memory(config, project: Any) -> str: + """Enable or strip unified_memory from *config* from *project* flags. + + Returns ``off``, ``file``, or ``vector-unavailable``. + """ + if not getattr(project, 'memory_enabled', False): + _drop_memory(config) + return 'off' + + backend = getattr(project, 'memory_backend', None) or 'file' + if backend == 'vector': + # Match WebUI: never write a file MEMORY.md the vector UI would hide. + _drop_memory(config) + return 'vector-unavailable' + if backend not in ('file', None, ''): + backend = 'file' + + pid = getattr(project, 'id', None) or 'default' + node = { + 'storage': {'backend': 'file'}, + 'namespace': {'user_id': pid}, + 'user_id': pid, + 'add_after_step': {'user_id': pid}, + } + OmegaConf.update(config, 'memory.unified_memory', node, merge=True) + mem_node = OmegaConf.select(config, 'memory', default=None) + if mem_node is not None: + for key in [k for k in mem_node if k != 'unified_memory']: + del mem_node[key] + return 'file' diff --git a/ms_agent/project/manager.py b/ms_agent/project/manager.py index 03c91ef7b..1b0267573 100644 --- a/ms_agent/project/manager.py +++ b/ms_agent/project/manager.py @@ -156,8 +156,10 @@ def open_folder( ) -> Project: """Open an existing directory as a project (Codex "use an existing folder"). - Unlike :meth:`create`, the project's identity **is the folder**: - ``id = project_key(path)``. Consequences: + Unlike :meth:`create`, a brand-new mount uses ``id = project_key(path)``. + If this folder is already a registered project — including one created + with a random id — that record is returned so TUI and WebUI share one + session tree. - **Dedup by path** — reopening the same folder returns the same project (no duplicate), so history is continuous across reopens. @@ -169,10 +171,10 @@ def open_folder( from ms_agent.project.paths import project_key work_dir = str(Path(os.path.expanduser(path)).resolve()) - project_id = project_key(work_dir) - existing = self.get(project_id) + existing = self.find_by_path(work_dir) if existing is not None: return existing + project_id = project_key(work_dir) project = Project( id=project_id, name=name or Path(work_dir).name or project_id, @@ -192,6 +194,25 @@ def get(self, project_id: str) -> Project | None: data = store.read() return Project(**{k: v for k, v in data.items() if k in Project.__dataclass_fields__}) if data else None + def find_by_path(self, path: str) -> Project | None: + """Return the registered project whose ``path`` is this directory. + + Used so a WebUI ``create()`` project (random id, path = that folder) + and a later TUI/WebUI ``open_folder`` of the same directory stay one + project. Comparison is on resolved absolute paths. + """ + try: + work_dir = str(Path(os.path.expanduser(path)).resolve()) + except OSError: + return None + for project in self.list(): + try: + if str(Path(project.path).expanduser().resolve()) == work_dir: + return project + except OSError: + continue + return None + def list(self) -> list[Project]: projects: list[Project] = [] try: diff --git a/ms_agent/project/session.py b/ms_agent/project/session.py index 36661a3da..1b22bf6ba 100644 --- a/ms_agent/project/session.py +++ b/ms_agent/project/session.py @@ -204,3 +204,36 @@ def _save_meta(self, session: Session) -> None: data.update(asdict(session)) data['status'] = session.status.value store.write(data) + + +def persist_logged_session_model(config, model: str, + provider: str | None) -> bool: + """Write this conversation's model onto its ``session.json``. + + The global default (``settings.json`` ``default_model``) is a separate + write. New chats copy that default; reopening this chat reads the fields + stored here and does not change the default. Returns False when ``config`` + is not pointed at a SessionManager directory. + """ + from omegaconf import OmegaConf + + raw = OmegaConf.select(config, 'session_log.dir', default='') or '' + session_dir = Path(str(raw)).expanduser() + if (not raw or session_dir.parent.name != 'sessions' + or session_dir.parent.parent.parent.name != 'projects'): + return False + project_id = session_dir.parent.parent.name + home = session_dir.parent.parent.parent.parent + from ms_agent.project.manager import ProjectManager + project = ProjectManager(str(home), auto_initialize=False).get(project_id) + if project is None: + return False + manager = SessionManager(project, base_dir=home, auto_initialize=False) + if manager.get(session_dir.name) is None: + return False + manager.update( + session_dir.name, + model=model or None, + model_provider=provider or None, + ) + return True diff --git a/ms_agent/prompting/workspace_files.py b/ms_agent/prompting/workspace_files.py index d07e56b95..210508a0a 100644 --- a/ms_agent/prompting/workspace_files.py +++ b/ms_agent/prompting/workspace_files.py @@ -419,6 +419,146 @@ def write_home_file(name: str, text: str) -> None: split_regions = split_profile_regions +# ── UI write API (TUI slash commands / WebUI settings pages) ───────────────── +# +# Global instructions: edit only the user region under the seeded AGENTS.md +# header. Project instructions: the private slot /.ms_agent/AGENTS.md — +# never the repo-root AGENTS.md (AI-native repos commit that file themselves). +# PROFILE.md: managed ``- Call me:`` line + free region. + +_AGENTS = 'AGENTS.md' +_PROFILE = 'PROFILE.md' + + +def read_global_instruction() -> str: + """User region of ``~/.ms_agent/AGENTS.md`` (legacy settings migrated).""" + return _migrate_legacy_global_instruction().strip() + + +def write_global_instruction(content: str) -> None: + """Replace the user region; keep the seeded header/comments.""" + _migrate_legacy_global_instruction() + text = read_home_file(_AGENTS) + body = content.strip() + write_home_file(_AGENTS, set_free_region(text, body + '\n' if body else '')) + + +def project_instruction_path(work_dir: str) -> Path: + return local_internal_dir(work_dir) / _AGENTS + + +def read_project_instruction(work_dir: str) -> str: + """Private slot ``/.ms_agent/AGENTS.md`` (legacy project.instruction + migrated). Does not read or write the repo-root ``AGENTS.md``.""" + path = project_instruction_path(work_dir) + try: + content = path.read_text(encoding='utf-8') + except OSError: + content = '' + if content.strip(): + return content + return _migrate_legacy_project_instruction(work_dir) + + +def write_project_instruction(work_dir: str, content: str) -> None: + """Write the private slot only; never touch ``/AGENTS.md``.""" + _migrate_legacy_project_instruction(work_dir) + body = content.strip() + atomic_write_text(project_instruction_path(work_dir), + body + '\n' if body else '') + # Always drop the leftover JSON field so an empty private file cannot be + # re-filled from a stale project.instruction on the next read. + _clear_project_instruction_field(work_dir) + + +def read_profile() -> Tuple[str, str]: + """``(call_me, free_region)`` from ``~/.ms_agent/PROFILE.md``.""" + text = read_home_file(_PROFILE) + return get_call_me(text), get_free_region(text).strip() + + +def write_profile(*, + call_me: Optional[str] = None, + description: Optional[str] = None) -> Tuple[str, str]: + """Update PROFILE.md regions. ``None`` leaves that region unchanged.""" + text = read_home_file(_PROFILE) + if call_me is not None: + text = set_call_me(text, call_me) + if description is not None: + body = description.strip() + text = set_free_region(text, body + '\n' if body else '') + write_home_file(_PROFILE, text) + return get_call_me(text), get_free_region(text).strip() + + +def _migrate_legacy_global_instruction() -> str: + """Move settings.json personalization.global_instruction into AGENTS.md.""" + text = read_home_file(_AGENTS) + user_region = get_free_region(text) + if user_region.strip(): + return user_region + try: + from ms_agent.personalization.settings import PersonalizationSettings + from ms_agent.personalization.types import PersonalizationConfig + ps = PersonalizationSettings() + cur = ps.load() + legacy = (cur.global_instruction or '').strip() + if not legacy: + return user_region + text = set_free_region(text, legacy + '\n') + write_home_file(_AGENTS, text) + ps.save( + PersonalizationConfig( + global_instruction='', + memory_enabled=cur.memory_enabled, + memory_backend=cur.memory_backend, + )) + return get_free_region(text) + except Exception: + logger.debug( + 'legacy global_instruction migration skipped', exc_info=True) + return user_region + + +def _migrate_legacy_project_instruction(work_dir: str) -> str: + """Move Project.instruction into ``.ms_agent/AGENTS.md`` once, then clear it.""" + path = project_instruction_path(work_dir) + try: + existing = path.read_text(encoding='utf-8') + except OSError: + existing = '' + if existing.strip(): + return existing + try: + from ms_agent.project.manager import ProjectManager + pm = ProjectManager(base_dir=str(global_home())) + project = pm.find_by_path(work_dir) + if project is None: + return existing + legacy = (project.instruction or '').strip() + if not legacy: + return existing + atomic_write_text(path, legacy + '\n') + _clear_project_instruction_field(work_dir) + return legacy + '\n' + except Exception: + logger.debug( + 'legacy project.instruction migration skipped', exc_info=True) + return existing + + +def _clear_project_instruction_field(work_dir: str) -> None: + try: + from ms_agent.project.manager import ProjectManager + pm = ProjectManager(base_dir=str(global_home())) + project = pm.find_by_path(work_dir) + if project is not None and (project.instruction or '').strip(): + pm.update(project.id, instruction='') + except Exception: + logger.debug( + 'clearing leftover project.instruction skipped', exc_info=True) + + # ── injected block builders (consumed by LLMAgent) ─────────────────────────── diff --git a/ms_agent/session/strategies/summary_compactor.py b/ms_agent/session/strategies/summary_compactor.py index ab9a61190..dea6787b7 100644 --- a/ms_agent/session/strategies/summary_compactor.py +++ b/ms_agent/session/strategies/summary_compactor.py @@ -25,7 +25,8 @@ - Relevant files: Files read, edited, or created Preserve verbatim, never rewrite or drop: the LATEST -skill-update notice, if any (it is the authoritative current skill list), +skill-update notice, if any (the system prompt's skill list may be stale; +skills_list is current), and the LATEST notice about changed workspace/prompt files, if any (it explains why earlier conversation may reference outdated file contents). Other blocks (e.g. retrieved memories) diff --git a/ms_agent/skill/harness.py b/ms_agent/skill/harness.py new file mode 100644 index 000000000..2a43559fe --- /dev/null +++ b/ms_agent/skill/harness.py @@ -0,0 +1,52 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Live path substitution for bundled harness skills. + +No harness skill is bundled right now. Ids in ``HARNESS_SKILL_IDS`` get +``{home}`` / ``{work}`` / … filled at ``skill_view`` and slash-expand time. +Every other skill is left unchanged. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional + +HARNESS_SKILL_IDS: frozenset[str] = frozenset() + + +def harness_placeholders(config: Any = None) -> Dict[str, str]: + """Resolve the machine's current home / work / memory / MCP paths.""" + from ms_agent.project.paths import global_home, memory_dir + from ms_agent.utils.workspace_context import resolve_workspace_root + + try: + home = str(global_home()) + except Exception: # noqa: BLE001 - never break skill_view + home = str(Path.home() / '.ms_agent') + try: + work = str(resolve_workspace_root(config)) + except Exception: # noqa: BLE001 + work = str(Path.cwd().resolve()) + return { + 'home': home, + 'work': work, + 'memory_md': str(memory_dir(work) / 'MEMORY.md'), + 'project_mcp': str(Path(work) / '.ms_agent' / 'mcp.json'), + 'global_mcp': str(Path(home) / 'mcp.json'), + } + + +def fill_harness_placeholders( + text: str, + config: Any = None, + skill_id: Optional[str] = None) -> str: + """Replace ``{home}`` / ``{work}`` / … in a harness skill body. + + Token replace (not ``str.format``) so JSON examples with other braces + stay intact. Unknown skill ids are left unchanged. + """ + if skill_id is not None and skill_id not in HARNESS_SKILL_IDS: + return text + mapping = harness_placeholders(config) + for key, value in mapping.items(): + text = text.replace('{' + key + '}', value) + return text diff --git a/ms_agent/skill/loader.py b/ms_agent/skill/loader.py index 9d3c8b244..360c02d58 100644 --- a/ms_agent/skill/loader.py +++ b/ms_agent/skill/loader.py @@ -181,15 +181,20 @@ def _discover_single_skill(self, return cached[1] content = skill_md.read_text(encoding='utf-8') - frontmatter = self.parser.parse_yaml_frontmatter(content) - if (not frontmatter or 'name' not in frontmatter - or 'description' not in frontmatter): + frontmatter = self.parser.parse_yaml_frontmatter(content) or {} + # Presence of SKILL.md is registration (same as WebUI live tree). + # Name/description are filled from the directory when omitted so a + # TUI ``/skills add`` still shows up on the skills page. + name = str(frontmatter.get('name') or skill_dir.name).strip() + description = str( + frontmatter.get('description') or name).strip() or name + if not name: self._discovery_cache[skill_dir] = (fingerprint, None) return None descriptor = SkillDescriptor( skill_id=skill_dir.name, - name=frontmatter['name'], - description=frontmatter['description'], + name=name, + description=description, content=content, version=frontmatter.get('version', 'latest'), author=frontmatter.get('author'), diff --git a/ms_agent/skill/prompt_injector.py b/ms_agent/skill/prompt_injector.py index c411a4aea..d809fb7e2 100644 --- a/ms_agent/skill/prompt_injector.py +++ b/ms_agent/skill/prompt_injector.py @@ -33,13 +33,12 @@ class SkillPromptInjector: UPDATE_NOTICE_HINT = """ **About skill list updates:** The skill list here reflects the state when this session started. Skills -can be added, removed, enabled, disabled or edited mid-conversation; when -that happens, a skill-update notice containing the FULL -current list appears in the conversation. -- If any skill-update notice exists, the LATEST one is authoritative and - supersedes this list and all earlier notices. -- Earlier notices may be outdated; that is normal bookkeeping — do not - mention notices or list changes to the user unless asked. +can be added, removed, enabled, disabled or edited mid-conversation. A + skill-update notice may appear when that happens. It +does not contain the skill list. Call `skills_list` for the current +inventory. +- skills_list is current. This prompt's list may be stale. +- Do not mention notices or list changes to the user unless asked. """ def __init__(self, diff --git a/ms_agent/skill/schema.py b/ms_agent/skill/schema.py index c513b02fc..452a6cbdb 100644 --- a/ms_agent/skill/schema.py +++ b/ms_agent/skill/schema.py @@ -272,13 +272,13 @@ def parse_skill_directory(directory_path: str | Path) -> Optional[SkillSchema]: with open(skill_md_path, 'r', encoding='utf-8') as f: content = f.read() - # Parse metadata - frontmatter = SkillSchemaParser.parse_yaml_frontmatter(content) - if not frontmatter or 'name' not in frontmatter or 'description' not in frontmatter: - return None - - # Generate skill_id from directory name + # Parse metadata. SKILL.md is enough to register; fill missing + # name/description so a live-tree import still loads. + frontmatter = SkillSchemaParser.parse_yaml_frontmatter(content) or {} skill_id = directory_path.name + name = str(frontmatter.get('name') or skill_id).strip() or skill_id + description = str( + frontmatter.get('description') or name).strip() or name # Collect all files files = [] @@ -357,8 +357,8 @@ def _walk(current_path: Path, relative_dir: str, schema = SkillSchema( skill_id=skill_id, - name=frontmatter['name'], - description=frontmatter['description'], + name=name, + description=description, content=content, version=frontmatter.get('version', 'latest'), files=files, diff --git a/ms_agent/skill/skill_tools.py b/ms_agent/skill/skill_tools.py index d79b3b622..0d95fd315 100644 --- a/ms_agent/skill/skill_tools.py +++ b/ms_agent/skill/skill_tools.py @@ -8,6 +8,7 @@ from ms_agent.tools.base import ToolBase from ms_agent.utils.logger import get_logger from .catalog import USER_SKILLS_DIR +from .harness import fill_harness_placeholders from .schema import SkillSchemaParser logger = get_logger() @@ -34,6 +35,8 @@ def __init__(self, search_engine=None): super().__init__(config) self._catalog = catalog + # Agent config (has output_dir); catalog._config is skills-only. + catalog._agent_config = config self._enable_manage = enable_manage self._tool_manager = tool_manager self._search_engine = search_engine @@ -259,7 +262,8 @@ def _handle_skill_view(self, args: dict) -> str: 'name': skill.name, 'description': skill.description, 'skill_dir': str(skill.skill_path), - 'content': skill.content, + 'content': fill_harness_placeholders( + skill.content, self.config, skill.skill_id), 'linked_files': { 'scripts': [s.name for s in skill.scripts], 'references': [r.name for r in skill.references], diff --git a/ms_agent/tools/code/code_executor.py b/ms_agent/tools/code/code_executor.py index cfe43c3b5..edab2bcbc 100644 --- a/ms_agent/tools/code/code_executor.py +++ b/ms_agent/tools/code/code_executor.py @@ -8,6 +8,7 @@ from ms_agent.llm.utils import Tool from ms_agent.tools.base import ToolBase +from ms_agent.tools.code.local_code_executor import drop_unknown_call_args from ms_agent.tools.code.sandbox_manager import SandboxManagerFactory from ms_agent.utils import get_logger from ms_agent.utils.constants import DEFAULT_OUTPUT_DIR @@ -505,7 +506,7 @@ async def call_tool(self, server_name: str, *, tool_name: str, try: method = getattr(self, tool_name) - return await method(**tool_args) + return await method(**drop_unknown_call_args(method, tool_args or {})) except AttributeError: return json.dumps( { diff --git a/ms_agent/tools/code/local_code_executor.py b/ms_agent/tools/code/local_code_executor.py index 324f2ca49..bfec1caab 100644 --- a/ms_agent/tools/code/local_code_executor.py +++ b/ms_agent/tools/code/local_code_executor.py @@ -20,6 +20,28 @@ logger = get_logger() + +def drop_unknown_call_args(method, call_args: dict) -> dict: + """Drop arguments the target function does not accept. + + Models often add ``description``. A function that takes ``**kwargs`` + keeps every field. + """ + try: + params = inspect.signature(method).parameters + except (TypeError, ValueError): + return call_args + if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()): + return call_args + allowed = { + name + for name, p in params.items() + if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY) + } + return {key: value for key, value in call_args.items() if key in allowed} + + #: Ambient variables a POSIX command may read without them carrying anything #: the agent should not have. The environment is built as an allow-list rather #: than inherited, so anything missing here is simply absent for every command @@ -174,18 +196,22 @@ async def stop(self) -> None: return logger.info('Stopping local ipykernel session...') - if self._client: - stop_channels_result = self._client.stop_channels() - if inspect.isawaitable(stop_channels_result): - await stop_channels_result - if self._km: - shutdown_result = self._km.shutdown_kernel(now=True) - if inspect.isawaitable(shutdown_result): - await shutdown_result - self._client = None - self._km = None - self.start_ts = None - self.execution_count = 0 + try: + if self._client: + stop_channels_result = self._client.stop_channels() + if inspect.isawaitable(stop_channels_result): + await stop_channels_result + if self._km: + shutdown_result = self._km.shutdown_kernel(now=True) + if inspect.isawaitable(shutdown_result): + await shutdown_result + except (asyncio.CancelledError, Exception): # noqa: BLE001 + logger.debug('ipykernel stop interrupted', exc_info=True) + finally: + self._client = None + self._km = None + self.start_ts = None + self.execution_count = 0 async def restart(self) -> None: if not self._km: @@ -494,7 +520,10 @@ async def cleanup(self) -> None: if not self._initialized: return if self._uses_kernel: - await self.kernel_session.stop() + try: + await self.kernel_session.stop() + except (asyncio.CancelledError, Exception): # noqa: BLE001 + logger.debug('code executor cleanup interrupted', exc_info=True) self._initialized = False async def _get_tools_inner(self) -> Dict[str, Any]: @@ -711,6 +740,7 @@ async def call_tool(self, server_name: str, *, tool_name: str, call_args = dict(tool_args or {}) if '__call_id' in call_args and 'call_id' not in call_args: call_args['call_id'] = call_args.pop('__call_id') + call_args = drop_unknown_call_args(method, call_args) return await method(**call_args) except AttributeError: return json.dumps( diff --git a/ms_agent/tools/filesystem_tool.py b/ms_agent/tools/filesystem_tool.py index d5031cc0c..beb00a01f 100644 --- a/ms_agent/tools/filesystem_tool.py +++ b/ms_agent/tools/filesystem_tool.py @@ -119,8 +119,9 @@ def __init__(self, config, **kwargs): False) if not self.trust_remote_code: self.allow_read_all_files = False - if hasattr(self.config, 'llm'): - self.llm: LLM = LLM.from_config(self.config) + # Built at first abbreviated-read, not here: a missing API key must + # not block ToolManager (TUI setup still needs slash commands). + self.llm = None index_dir = getattr(config, 'index_cache_dir', DEFAULT_INDEX_DIR) self.index_dir = os.path.join(self.output_dir, index_dir) self.system = self.SYSTEM_FOR_ABBREVIATIONS @@ -1004,6 +1005,11 @@ async def read_file(self, return {'result': text, 'attachments': image_refs} return text + def _ensure_llm(self) -> LLM: + if self.llm is None: + self.llm = LLM.from_config(self.config) + return self.llm + async def _read_files_abbreviated(self, paths: list[str]) -> str: results = {} @@ -1024,6 +1030,7 @@ def process_file(path): with open(target_path_real, 'r', encoding='utf-8') as f: content = f.read() + llm = self._ensure_llm() messages = [ Message(role='system', content=self.system), Message( @@ -1031,7 +1038,7 @@ def process_file(path): content='The content to be abbreviated:\n\n' + content), ] - response = self.llm.generate(messages=messages, stream=False) + response = llm.generate(messages=messages, stream=False) os.makedirs(os.path.dirname(index_file), exist_ok=True) with open(index_file, 'w', encoding='utf-8') as f: f.write(response.content) diff --git a/ms_agent/tools/mcp_client.py b/ms_agent/tools/mcp_client.py index 9fd7da7d0..d27d575c9 100644 --- a/ms_agent/tools/mcp_client.py +++ b/ms_agent/tools/mcp_client.py @@ -2,10 +2,15 @@ # Copyright (c) ModelScope Contributors. All rights reserved. import asyncio +import base64 import copy +import inspect +import json +import mimetypes import os import re import shutil +import uuid from contextlib import AsyncExitStack, suppress from datetime import timedelta from mcp import ClientSession, ListToolsResult, StdioServerParameters @@ -23,6 +28,131 @@ logger = get_logger() + +def _session_read_timeout(seconds: float): + """Value ``ClientSession`` accepts for ``read_timeout_seconds``. + + mcp 1.x takes a ``timedelta``. mcp 2.x takes seconds as a float and + adds that number to ``anyio.current_time()``. + """ + param = inspect.signature( + ClientSession.__init__).parameters.get('read_timeout_seconds') + annotation = '' if param is None else str(param.annotation) + if 'timedelta' in annotation and 'float' not in annotation: + return timedelta(seconds=seconds) + return float(seconds) + + +def _tool_call_is_error(response) -> bool: + """Whether an MCP ``call_tool`` result is an error. + + mcp 1.x exposes ``isError``. mcp 2.x renamed it to ``is_error``. + """ + flag = getattr(response, 'is_error', None) + if flag is None: + flag = getattr(response, 'isError', None) + return bool(flag) + + +def _tool_output_schema(tool): + """Return schema, whichever spelling this SDK version uses.""" + schema = getattr(tool, 'output_schema', None) + if schema is None: + schema = getattr(tool, 'outputSchema', None) + return schema if isinstance(schema, dict) else None + + +def _describe_tool(tool) -> str: + """Description plus the declared return shape. + + The chat-completions tool object only has a description, so a server that + published an output schema would otherwise never show the model what a + successful call looks like. + """ + description = getattr(tool, 'description', None) or '' + schema = _tool_output_schema(tool) + if not schema: + return description + rendered = json.dumps(schema, ensure_ascii=False) + if len(rendered) > 800: + rendered = rendered[:800] + '…' + return f'{description}\nReturns: {rendered}'.strip() + + +def _structured_content(response): + value = getattr(response, 'structured_content', None) + if value is None: + value = getattr(response, 'structuredContent', None) + return value + + +def _content_mime(content) -> str: + mime = getattr(content, 'mime_type', None) + if not mime: + mime = getattr(content, 'mimeType', None) + return str(mime or 'application/octet-stream') + + +def _resource_json(resource) -> str: + """Serialize an embedded resource on either SDK spelling.""" + dump_json = getattr(resource, 'model_dump_json', None) + if callable(dump_json): + try: + return dump_json(by_alias=True) + except TypeError: + return dump_json() + dump = getattr(resource, 'model_dump', None) + if callable(dump): + try: + return json.dumps(dump(by_alias=True), ensure_ascii=False) + except TypeError: + return json.dumps(dump(), ensure_ascii=False) + if isinstance(resource, dict): + return json.dumps(resource, ensure_ascii=False) + return json.dumps(getattr(resource, '__dict__', str(resource)), + default=str, + ensure_ascii=False) + + +def _tool_input_schema(tool) -> dict: + """JSON schema for one MCP tool. + + mcp 1.x exposes ``inputSchema``. mcp 2.x renamed it to ``input_schema`` + and dropped the old attribute. + """ + schema = getattr(tool, 'input_schema', None) + if schema is None: + schema = getattr(tool, 'inputSchema', None) + if isinstance(schema, dict): + return schema + return {'type': 'object', 'properties': {}} + + +def _retrieve_future(fut) -> None: + """Mark a finished future's exception as retrieved. + + ``ready.set_exception(CancelledError())`` inside a connect that quit + cancelled otherwise logs ``Future exception was never retrieved`` when the + loop later garbage-collects it — after the user has already seen bye. + """ + if fut is None or not fut.done() or fut.cancelled(): + return + try: + fut.exception() + except (asyncio.CancelledError, Exception): # noqa: BLE001 + return + + +def _is_teardown_noise(exc: BaseException) -> bool: + """MCP SDK / anyio errors that fire when closing streamable_http.""" + text = f'{type(exc).__name__}: {exc}' + return any(s in text for s in ( + 'cancel scope', + 'athrow()', + 'asynchronous generator', + )) + + EncodingErrorHandler = Literal['strict', 'ignore', 'replace'] DEFAULT_ENCODING = 'utf-8' @@ -43,6 +173,11 @@ def _int_env(name: str, default: int) -> int: CONNECTION_TIMEOUT = _int_env('CONNECTION_TIMEOUT', 120) +#: Per-server cap for the probe that runs before a turn. WebUI's health check +#: uses the same 6 seconds (``mcp_health.filter_healthy``). A dead address must +#: not sit on ``CONNECTION_TIMEOUT`` (120s) before the model is allowed to run. +MCP_PROBE_TIMEOUT = 6 + #: How many servers may be started at once. Bounded because each stdio server #: is a child process and a large config would otherwise fork all of them #: simultaneously. @@ -174,6 +309,12 @@ def __init__( # let go. self._server_tasks: Dict[str, 'asyncio.Task'] = {} self._server_shutdown: Dict[str, asyncio.Event] = {} + # The Future ``connect_to_server`` waits on. Kept so a cancelled + # connect can still retrieve the CancelledError it stored there. + self._server_ready: Dict[str, 'asyncio.Future'] = {} + # Every owner task, including ones already popped from ``_server_tasks`` + # while their teardown is still running. + self._live_tasks: set['asyncio.Task'] = set() self.exit_stack = AsyncExitStack() self.mcp_config: Dict[str, Dict[str, Any]] = {'mcpServers': {}} if config is not None: @@ -203,7 +344,8 @@ async def call_tool(self, server_name: str, tool_name: str, texts = [] resources = [] - if response.isError: + attachments = [] + if _tool_call_is_error(response): sep = '\n\n' if all(isinstance(item, str) for item in response.content): detail = sep.join(response.content) @@ -222,19 +364,67 @@ async def call_tool(self, server_name: str, tool_name: str, 'is_error': True, } - for content in response.content: - if content.type == 'text': + for content in response.content or []: + kind = getattr(content, 'type', None) + if kind == 'text': texts.append(content.text) - elif content.type == 'resource': + elif kind == 'resource': import json5 - json_str = content.resource.model_dump_json(by_alias=True) + json_str = _resource_json(content.resource) texts.append(json_str) resources.append(json5.loads(json_str)) - - if resources: - return {'text': '\n\n'.join(texts), 'resources': resources} - - return '\n\n'.join(texts) + elif kind == 'image': + path = self._write_media( + getattr(content, 'data', '') or '', + _content_mime(content), 'image') + attachments.append({ + 'type': 'image', + 'path': path, + 'media_type': _content_mime(content), + 'label': f'Image: {os.path.basename(path)}', + }) + texts.append(f'Image returned ({_content_mime(content)}).') + elif kind == 'audio': + path = self._write_media( + getattr(content, 'data', '') or '', + _content_mime(content), 'audio') + texts.append( + f'Audio returned ({_content_mime(content)}), saved at {path}.') + + structured = _structured_content(response) + if structured is not None and not texts: + texts.append( + json.dumps(structured, ensure_ascii=False, default=str)) + + body = '\n\n'.join(texts) + if resources or attachments: + result = {'text': body} + if resources: + result['resources'] = resources + if attachments: + result['attachments'] = attachments + return result + return body + + def _write_media(self, data: str, mime: str, kind: str) -> str: + """Persist a base64 tool result next to the workspace. + + Image refs the model can see are file paths, not inline bytes. Audio + has no model channel, so the path in the text is the whole delivery. + """ + raw = base64.b64decode(data or '') + ext = mimetypes.guess_extension(mime.split(';', 1)[0].strip()) or '' + if ext == '.jpe': + ext = '.jpg' + if not ext: + ext = '.png' if kind == 'image' else '.bin' + root = self.output_dir or os.getcwd() + folder = os.path.join(root, 'mcp_media') + os.makedirs(folder, exist_ok=True) + path = os.path.join(folder, f'{uuid.uuid4().hex}{ext}') + with open(path, 'wb') as handle: + handle.write(raw) + return os.path.relpath(path, root) def _filter_session_tools( self, @@ -254,8 +444,8 @@ def _filter_session_tools( Tool( tool_name=t.name, server_name=server_name, - description=t.description, - parameters=t.inputSchema, + description=_describe_tool(t), + parameters=_tool_input_schema(t), ) for t in session_tools ] @@ -317,17 +507,26 @@ def list_connected_servers(self) -> list[str]: def is_connected(self, server_name: str) -> bool: return server_name in self.sessions - async def disconnect_server(self, server_name: str) -> None: + def _track_task(self, task: 'asyncio.Task') -> None: + self._live_tasks.add(task) + task.add_done_callback(self._live_tasks.discard) + task.add_done_callback(_retrieve_future) + + async def disconnect_server(self, + server_name: str, + *, + graceful: bool = True) -> None: """Disconnect a single MCP server.""" self.exclude_functions.pop(server_name, None) self.include_functions.pop(server_name, None) - await self._stop_server(server_name) + await self._stop_server(server_name, graceful=graceful) async def connect_single_server( self, server_name: str, server_config: Dict[str, Any], timeout: int = CONNECTION_TIMEOUT, + startup_timeout: Optional[float] = None, ) -> str: """Connect one server from a normalized config entry.""" if self.is_connected(server_name): @@ -348,6 +547,7 @@ async def connect_single_server( server_name=server_name, env=env_dict, timeout=timeout, + startup_timeout=startup_timeout, **server, ) @@ -428,7 +628,7 @@ async def _open_session(self, stack: AsyncExitStack, server_name: str, ClientSession( read, write, - read_timeout_seconds=timedelta(seconds=timeout), + read_timeout_seconds=_session_read_timeout(timeout), **session_kwargs)) elif command: @@ -463,7 +663,7 @@ async def _open_session(self, stack: AsyncExitStack, server_name: str, ClientSession( stdio, write, - read_timeout_seconds=timedelta(seconds=read_timeout), + read_timeout_seconds=_session_read_timeout(read_timeout), **session_kwargs)) else: raise ValueError( @@ -500,7 +700,11 @@ async def _own_server( except BaseException as exc: # noqa: BLE001 if not ready.done(): ready.set_exception(exc) - elif not isinstance(exc, asyncio.CancelledError): + elif isinstance(exc, asyncio.CancelledError): + pass + elif _is_teardown_noise(exc): + logger.debug('MCP server %s closed: %s', server_name, exc) + else: logger.warning('MCP server %s dropped: %s', server_name, exc) finally: self.sessions.pop(server_name, None) @@ -508,12 +712,15 @@ async def _own_server( async def connect_to_server(self, server_name: str, timeout: int = CONNECTION_TIMEOUT, - startup_timeout: Optional[int] = None, + startup_timeout: Optional[float] = None, **kwargs): if self.is_connected(server_name): return server_name logger.info(f'connect to {server_name}') ready: 'asyncio.Future' = asyncio.get_running_loop().create_future() + # Retrieve as soon as the owner stores an exception, including the + # CancelledError a quit leaves behind after the waiter has moved on. + ready.add_done_callback(_retrieve_future) shutdown = asyncio.Event() task = asyncio.create_task( self._own_server(server_name, { @@ -521,8 +728,10 @@ async def connect_to_server(self, **kwargs }, ready, shutdown), name=f'mcp-server:{server_name}') + self._track_task(task) self._server_tasks[server_name] = task self._server_shutdown[server_name] = shutdown + self._server_ready[server_name] = ready try: if startup_timeout: # Shield so a timeout stops US waiting without cancelling the @@ -537,6 +746,7 @@ async def connect_to_server(self, # the startup timeout the caller already spent. await self._stop_server(server_name, graceful=False) raise + self._server_ready.pop(server_name, None) return server_name async def _stop_server(self, @@ -545,24 +755,40 @@ async def _stop_server(self, """Ask a server's owner task to close its transport, and wait for it.""" shutdown = self._server_shutdown.pop(server_name, None) task = self._server_tasks.pop(server_name, None) + ready = self._server_ready.pop(server_name, None) self.sessions.pop(server_name, None) if shutdown is not None: shutdown.set() if task is None or task.done(): + _retrieve_future(task) + _retrieve_future(ready) return if graceful: try: await asyncio.wait_for( asyncio.shield(task), timeout=SERVER_STOP_TIMEOUT) + _retrieve_future(ready) + return + except asyncio.TimeoutError: + logger.debug('MCP server %s stop timed out', server_name) + _retrieve_future(ready) + except asyncio.CancelledError: + # The TUI generator is closing. The owner already has the + # shutdown signal — wait it out. Cancelling it here would + # athrow() streamablehttp_client from the wrong task. + with suppress(BaseException): + await asyncio.shield(task) + _retrieve_future(ready) return - except (asyncio.TimeoutError, asyncio.CancelledError): - pass except BaseException as exc: # noqa: BLE001 logger.debug('MCP server %s stopped with %s', server_name, exc) + _retrieve_future(ready) return task.cancel() with suppress(BaseException): await task + _retrieve_future(task) + _retrieve_future(ready) def _plan_server(self, name: str, server: Dict[str, Any], default_timeout: int) -> Dict[str, Any]: @@ -675,11 +901,60 @@ async def add_mcp_config(self, mcp_config: Dict[str, Dict[str, Any]]): server_name=name, env=env_dict, **server) self.mcp_config['mcpServers'].update(new_mcp_config) + async def _reap_live_tasks(self) -> None: + """Stop owner tasks that a cancelled connect did not finish awaiting. + + Quit during a probe used to leave those tasks for the loop shutdown, + which then printed ``CancelledError`` after bye. + """ + # A server nobody has signalled yet is still inside connect. Cancel + # it. One whose shutdown event is already set is closing itself; + # cancelling that owner exits its anyio scope in the wrong task. + for name, task in list(self._server_tasks.items()): + if task.done(): + continue + shutdown = self._server_shutdown.get(name) + if shutdown is None or not shutdown.is_set(): + task.cancel() + pending = [task for task in list(self._live_tasks) if not task.done()] + readies = list(self._server_ready.values()) + self._server_tasks.clear() + self._server_shutdown.clear() + self._server_ready.clear() + self.sessions.clear() + try: + if pending: + await asyncio.shield( + asyncio.gather(*pending, return_exceptions=True)) + except asyncio.CancelledError: + return + finally: + for task in pending: + _retrieve_future(task) + for ready in readies: + _retrieve_future(ready) + async def cleanup(self): """Clean up resources""" - for name in list(self._server_tasks): - await self.disconnect_server(name) - await self.exit_stack.aclose() + try: + for name in list(self._server_tasks): + try: + # Not initialized yet: there is no session to drain, and + # the grace period would hang bye on the probe that just + # timed out. + established = name in self.sessions + await self.disconnect_server(name, graceful=established) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + logger.debug( + 'MCP disconnect %s during cleanup', + name, + exc_info=True) + finally: + await self._reap_live_tasks() + with suppress(BaseException): + await self.exit_stack.aclose() async def __aenter__(self) -> 'MCPClient': try: diff --git a/ms_agent/tools/search/availability.py b/ms_agent/tools/search/availability.py new file mode 100644 index 000000000..0f3b93b95 --- /dev/null +++ b/ms_agent/tools/search/availability.py @@ -0,0 +1,78 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Whether a search engine can be imported, and the sentence to show when not. + +The pip name is the thing a person can run. It is not always the import name +(``exa-py`` imports as ``exa_py``, ``google-search-results`` as ``serpapi``). +Tavily has no third-party package. A missing key is reported by the key +column, not by this sentence. +""" +from __future__ import annotations + +import importlib +from typing import Iterable, Mapping, Optional + +# engine id -> distribution name on PyPI +_PIP_NAME = { + 'arxiv': 'arxiv', + 'exa': 'exa-py', + 'serpapi': 'google-search-results', +} +# engine id -> module importlib should load +_IMPORT_NAME = { + 'arxiv': 'arxiv', + 'exa': 'exa_py', + 'serpapi': 'serpapi', +} + + +def _missing_pip(engine: str) -> Optional[str]: + pip_name = _PIP_NAME.get(engine) + import_name = _IMPORT_NAME.get(engine) + if not pip_name or not import_name: + return None + try: + importlib.import_module(import_name) + except ModuleNotFoundError: + return pip_name + return None + + +def unavailable_notice(engine: str, *, has_key: bool, + requires_key: bool) -> Optional[str]: + """One operable sentence, or None when this engine is not blocked on a package. + + ``requires_key and not has_key`` stays on the key line. Installing a + package would not make that engine run. + """ + if requires_key and not has_key: + return None + pip_name = _missing_pip(engine) + if not pip_name: + return None + return (f'UNAVAILABLE: Package `{pip_name}` is not installed. ' + f'Run: pip install {pip_name}, then restart ms-agent.') + + +def notices_for_engines(engines: Iterable[str], + api_keys: Mapping[str, object], + requires_key) -> Optional[str]: + """Join per-engine package sentences for a live web-search tool. + + ``requires_key`` is ``search_settings.requires_key``. One engine keeps its + sentence intact. Several missing packages share a single UNAVAILABLE label. + """ + lines = [] + for engine in engines: + line = unavailable_notice( + engine, + has_key=bool(api_keys.get(engine)), + requires_key=requires_key(engine), + ) + if line: + lines.append(line) + if not lines: + return None + if len(lines) == 1: + return lines[0] + bodies = [line.split(': ', 1)[1] for line in lines] + return 'UNAVAILABLE: ' + ' '.join(bodies) diff --git a/ms_agent/tools/search/websearch_tool.py b/ms_agent/tools/search/websearch_tool.py index a0f2e3e4e..e13a5c95d 100644 --- a/ms_agent/tools/search/websearch_tool.py +++ b/ms_agent/tools/search/websearch_tool.py @@ -746,6 +746,26 @@ def _bounded_json(self, response: Dict[str, Any]) -> str: out = _json_dumps(response) return out + async def reload_from_config(self) -> None: + """Rebuild engines after ``tools.web_search`` changes on this config.""" + fresh = type(self)(self.config) + keep = { + '_engines', + '_engine_classes', + '_content_fetcher', + '_content_optimizer', + '_executor', + '_summary_usage_total', + '_summary_usage_model', + } + for key, value in vars(fresh).items(): + if key in keep: + continue + setattr(self, key, value) + self._engines.clear() + self._engine_classes.clear() + await self.connect() + async def connect(self) -> None: """Initialize search engines and content fetcher.""" for engine_type in self._engine_types: diff --git a/ms_agent/tools/todolist_tool.py b/ms_agent/tools/todolist_tool.py index e9623986c..f8b7d8266 100644 --- a/ms_agent/tools/todolist_tool.py +++ b/ms_agent/tools/todolist_tool.py @@ -107,9 +107,17 @@ async def connect(self) -> None: _ensure_dir(self._lock_dir()) def _paths(self) -> _PlanPaths: + # Absolute filenames (WebUI / TUI session dirs) win; relative ones + # stay under output_dir as a project-shared plan. + plan_json = self._plan_filename + plan_md = self._plan_md_filename + if not os.path.isabs(plan_json): + plan_json = os.path.join(self.output_dir, plan_json) + if not os.path.isabs(plan_md): + plan_md = os.path.join(self.output_dir, plan_md) return _PlanPaths( - plan_json=os.path.join(self.output_dir, self._plan_filename), - plan_md=os.path.join(self.output_dir, self._plan_md_filename), + plan_json=plan_json, + plan_md=plan_md, lock_dir=self._lock_dir(), ) diff --git a/ms_agent/tools/tool_manager.py b/ms_agent/tools/tool_manager.py index 5d5473fb7..8e803d85c 100644 --- a/ms_agent/tools/tool_manager.py +++ b/ms_agent/tools/tool_manager.py @@ -38,6 +38,58 @@ logger = get_logger() + +def _must_propagate(exc: BaseException) -> bool: + """Cancellation and unknown base exceptions still leave the tool call. + + An ExceptionGroup from a dead MCP transport is not one of those: it used + to escape ``except Exception`` and end the turn as a generic failure. + """ + if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt, SystemExit, + GeneratorExit)): + return True + if type(exc).__name__ in ('ExceptionGroup', 'BaseExceptionGroup'): + members = getattr(exc, 'exceptions', None) + if isinstance(members, tuple) and members: + return all(_must_propagate(item) for item in members) + return False + return not isinstance(exc, Exception) + + +def _config_key(tool: ToolBase) -> Optional[str]: + """The ``tools.`` name /tools lists, when this object has one.""" + named = getattr(tool, 'SERVER_NAME', None) + if isinstance(named, str) and named: + return named + return { + 'FileSystemTool': 'file_system', + 'CodeExecutionTool': 'code_executor', + 'LocalCodeExecutionTool': 'code_executor', + 'ImageReaderTool': 'image_reader', + 'ImageGenerator': 'image_generator', + 'VideoGenerator': 'video_generator', + 'LocalSearchTool': 'local_search', + }.get(type(tool).__name__) + + +def _connect_failure_text(tool: ToolBase, exc: BaseException) -> str: + """One line for /tools. Package installs stay on the search sentence.""" + if type(tool).__name__ == 'WebSearchTool': + from ms_agent.config.search_settings import requires_key + from ms_agent.tools.search.availability import notices_for_engines + notice = notices_for_engines( + getattr(tool, '_engine_types', ()) or (), + getattr(tool, '_api_keys', {}) or {}, + requires_key, + ) + if notice: + return notice + line = ' '.join(str(exc).split()) + if len(line) > 120: + line = line[:119] + '…' + return f'UNAVAILABLE: {line}' + + MAX_TOOL_NAME_LEN = int(os.getenv('MAX_TOOL_NAME_LEN', 64)) # Default wait around each tool invocation (seconds). Override via config.tool_call_timeout or TOOL_CALL_TIMEOUT. TOOL_CALL_TIMEOUT = int(os.getenv('TOOL_CALL_TIMEOUT', 120)) @@ -173,6 +225,9 @@ def __init__( self.mcp_success_handler = mcp_success_handler self.extra_tools: List[ToolBase] = [] + # config key -> one-line reason. /tools reads this. MCP servers are + # not recorded here; /mcp list already shows those. + self.connect_failures: Dict[str, str] = {} self.has_split_task_tool = False if hasattr(config, 'tools') and hasattr(config.tools, 'image_generator'): @@ -351,6 +406,7 @@ async def connect(self): and self.mcp_config.get('mcpServers')): logger.warning_once( 'mcp package not installed; MCP tools disabled for this run') + self.connect_failures = {} for tool in self.extra_tools: try: await tool.connect() @@ -358,6 +414,10 @@ async def connect(self): logger.warning( f'Tool {getattr(tool, "name", type(tool).__name__)} ' f'failed to connect: {e}; disabling.') + key = _config_key(tool) + if key: + self.connect_failures[key] = _connect_failure_text( + tool, e) # reindex_tool() self-gates its MCP portion on _skip_mcp_reindex, so it # is always safe to call here: extra tools get indexed in every path, @@ -372,13 +432,13 @@ async def cleanup(self): if self._managed_client and self.servers: try: await self.servers.cleanup() - except Exception: # noqa + except (asyncio.CancelledError, Exception): # noqa pass self.servers = None for tool in self.extra_tools: try: await tool.cleanup() - except Exception: # noqa + except (asyncio.CancelledError, Exception): # noqa pass def _clear_mcp_index_entries(self) -> None: @@ -428,26 +488,45 @@ def _resolve_tool_name(self, tool_name: str) -> tuple: """Map what the model asked for onto a registered tool. Some models reach for the server segment alone — ``code_executor`` - instead of ``code_executor---shell_executor`` — and used to get an - AssertionError wrapped in a traceback, which says nothing about what - the name should have been. When the shorthand picks out exactly one - tool there is no ambiguity to resolve, so the call proceeds and the - result carries a note; otherwise it is reported as not found, with - candidates. + instead of ``code_executor---shell_executor`` — or for the tool + segment alone — ``skills_list`` instead of ``skills---skills_list``. + When that shorthand picks out exactly one registered name, the call + proceeds and the result carries a note. A fuzzy neighbour is not + renamed. Returns ``(resolved_name, note)``; ``resolved_name`` is ``None`` when nothing matched. """ if tool_name in self._tool_index: return tool_name, '' - prefix = f'{tool_name}{self.TOOL_SPLITER}' + splitter = self.TOOL_SPLITER + prefix = f'{tool_name}{splitter}' matches = [key for key in self._tool_index if key.startswith(prefix)] if len(matches) == 1: - return matches[0], ( - f'Note: "{tool_name}" was resolved to "{matches[0]}". ' - 'Use the exact tool name in subsequent calls.') + return matches[0], self._resolved_note(tool_name, matches[0]) + # A unique prefix is the only shorthand that may have several tools + # under one server. A written name that is exactly one registered + # suffix (``skills_list`` → ``skills---skills_list``) is the same + # kind of correction. Anything else, including a fuzzy neighbour, + # stays an error. + if not matches: + # Same segment call_tool receives: everything after the first + # separator. A tool id may itself contain ``---``. + suffix_matches = [ + key for key in self._tool_index + if splitter in key and self._registered_tool_suffix( + key, splitter) == tool_name + ] + if len(suffix_matches) == 1: + return suffix_matches[0], self._resolved_note( + tool_name, suffix_matches[0]) return None, '' + @staticmethod + def _resolved_note(asked: str, resolved: str) -> str: + return (f'Note: "{asked}" was resolved to "{resolved}". ' + 'Use the exact tool name in subsequent calls.') + def _unknown_tool_message(self, tool_name: str) -> str: """Say what to call instead, rather than how the lookup failed.""" import difflib @@ -831,7 +910,9 @@ async def single_call_tool(self, tool_info: ToolCall): 'is_error': True, } - except Exception as e: + except BaseException as e: + if _must_propagate(e): + raise import traceback tb_str = traceback.format_exc() logger.warning(tb_str) diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index aa25d4800..9577cd684 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -31,6 +31,7 @@ from ms_agent.config import Config from ms_agent.config.env import Env +from ms_agent.config.search_settings import DEFAULT_WEB_SEARCH_BLOCK from ms_agent.tui.input import PromptToolkitInput from ms_agent.tui.renderer import RichEventSink from ms_agent.tui.state import TuiState @@ -39,6 +40,30 @@ logger = get_logger() +# Same discriminator WebUI session_overrides use. Writing +# tools.todo_list.plan_filename without mcp:false makes ToolManager treat +# todo_list as an MCP server ('url' or 'command' parameter is required). +# Snapshots default off: TUI has no /rollback, and a home-dir work tree +# would git-add the whole $HOME on the first turn. +TUI_RESOLVER_DEFAULTS = { + 'enable_snapshots': False, + 'tools': { + 'todo_list': { + 'enabled': True, + 'mcp': False, + }, + 'web_search': dict(DEFAULT_WEB_SEARCH_BLOCK), + }, + # Non-empty so LLMAgent.prepare_skills runs. An empty ``{}`` is falsy + # under OmegaConf. User and project skills still load. + 'skills': { + 'prompt_injection': 'all', + # Freeze the skill section for the session. Changes are announced + # on the next user message (see tui/live_config.py). + 'update_notice': True, + }, +} + class TuiApp: @@ -51,6 +76,7 @@ def __init__( work_dir: Optional[str] = None, emit_events: Optional[str] = None, mcp_server_file: Optional[str] = None, + explicit_config: bool = False, ) -> None: Env.load_dotenv_into_environ(env_file) self.console = Console() @@ -59,9 +85,12 @@ def __init__( self.work_dir = str( Path(work_dir).expanduser().resolve() if work_dir else Path.cwd(). resolve()) + self._project = self._open_project(self.work_dir) - config = Config.from_task(config_path) - config = self._prepare_config(config, permission_mode, self.work_dir) + config = self._load_runtime_config( + config_path, self.work_dir, explicit_config=explicit_config) + config = self._prepare_config( + config, permission_mode, self.work_dir, self._project) self.config = config mode = str( @@ -84,16 +113,10 @@ def __init__( self._jsonl_sink = JsonlEventSink(emit_events) event_sink = TeeEventSink(self.renderer, self._jsonl_sink) - # Session layer (M1). Work dir == project (CC-aligned): sessions live at - # ~/.ms_agent/projects//sessions. + # Same path as WebUI: ProjectManager.open_folder (find-by-path, else + # path-key). Sessions then live under that project's id. from ms_agent.project import SessionManager - from ms_agent.project.paths import project_key - from ms_agent.project.types import Project - proj = Project( - id=project_key(self.work_dir), - name=Path(self.work_dir).name or 'project', - path=self.work_dir) - self._sm = SessionManager(proj) + self._sm = SessionManager(self._project) self.session = None # Bridge managed config files into the runtime (what a WebUI backend @@ -112,18 +135,36 @@ def __init__( # Build the agent ONCE with the UI seams injected. load_cache is set # per session by _apply_session (True only on resume). from ms_agent.agent.llm_agent import LLMAgent + from ms_agent.config.mcp_schema import ResolvedMCPConfig + from ms_agent.mcp.runtime import MCPRuntime + from ms_agent.tui.live_config import apply_live_config + servers = {} + if isinstance(self._mcp_config, dict): + servers = dict(self._mcp_config.get('mcpServers') or {}) + mcp_runtime = MCPRuntime( + config=ResolvedMCPConfig(mcp_servers=servers)) self.agent = LLMAgent( config, trust_remote_code=trust_remote_code, event_sink=event_sink, - mcp_config=self._mcp_config) + mcp_config=self._mcp_config, + mcp_runtime=mcp_runtime) + + async def _on_user_turn(messages): + await apply_live_config(self.agent, messages) + + self.agent._on_user_turn = _on_user_turn # Consume the agent's single command router (no duplicate); register the # TUI session commands and drive input through it (slash completion). self.router = self.agent._get_command_router() self._register_session_commands() self.input = PromptToolkitInput( - self.state, self.router, self.theme, console=self.console) + self.state, + self.router, + self.theme, + console=self.console, + renderer=self.renderer) self.agent._input_source = self.input # Always inject the TUI handler — it is only *called* in interactive @@ -141,11 +182,136 @@ def __init__( # ('new', None) | ('resume', '<#|id>') | None, set by session commands. self._pending_switch: Optional[Tuple[str, Optional[str]]] = None + # First chat collected while recovering from a missing API key. + self._queued_query: Optional[str] = None + # Sessions this TUI process minted. Empty leftovers may be pruned; + # WebUI (or another TUI) sessions must not. + self._owned_session_ids: set[str] = set() + + @staticmethod + def _apply_provider_credentials(config, overwrite: bool = False) -> None: + """Fill llm keys from settings.json ``providers.``. + + ``overwrite=True`` is for a live ``/model provider`` edit of the + current service (replace, don't only fill blanks). + """ + try: + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + raw = ModelSettingsManager(global_home())._load_raw() + except Exception: + return + if not isinstance(raw, dict): + raw = {} + + def _set(field, value, *, force: bool) -> None: + if value in (None, ''): + if force: + OmegaConf.update(config, field, '', merge=True) + return + if force or not OmegaConf.select(config, field, default=None): + OmegaConf.update(config, field, value, merge=True) + + service = str(OmegaConf.select(config, 'llm.service', default='') or '') + providers = raw.get('providers') or {} + entry = providers.get(service) if isinstance(providers.get(service), dict) else {} + if not entry and service: + for pid, item in providers.items(): + if str(pid).lower() == service.lower() and isinstance(item, dict): + entry = item + break + if not service: + return + # Canonicalize MiniMax → minimax so later lookups hit the builtin id. + from ms_agent.llm.spec import get_registry + spec = get_registry().get(service) + if spec is not None and spec.name != service: + OmegaConf.update(config, 'llm.service', spec.name, merge=True) + service = spec.name + if not entry: + hit = providers.get(spec.name) + if isinstance(hit, dict): + entry = hit + + key_field = f'llm.{service}_api_key' + url_field = f'llm.{service}_base_url' + _set(key_field, entry.get('api_key'), force=overwrite) + _set('llm.api_key', entry.get('api_key'), force=overwrite) + _set(url_field, entry.get('base_url'), force=overwrite) + _set('llm.base_url', entry.get('base_url'), force=overwrite) + proto = entry.get('protocol') + if proto and (overwrite or not OmegaConf.select( + config, 'llm.protocol', default=None)): + OmegaConf.update(config, 'llm.protocol', proto, merge=True) + + @staticmethod + def _open_project(work_dir: str): + """Same ProjectManager.open_folder path WebUI uses for a local folder. + + New mounts inherit PersonalizationSettings.memory_enabled (WebUI's + default for new projects). An already-registered folder keeps its + stored flags. + """ + from ms_agent.project import ProjectManager + from ms_agent.project.paths import global_home + from ms_agent.personalization.settings import PersonalizationSettings + + mem = False + backend = None + try: + loaded = PersonalizationSettings().load() + mem = bool(loaded.memory_enabled) + backend = loaded.memory_backend + except Exception: + pass + return ProjectManager(base_dir=str(global_home())).open_folder( + work_dir, memory_enabled=mem, memory_backend=backend) # -- config shaping -- @staticmethod - def _prepare_config(config, permission_mode, work_dir): + def _load_runtime_config(config_path: str, + work_dir: str, + *, + explicit_config: bool = False): + """Same layered merge WebUI uses (ConfigResolver), not Config.from_task + alone. + + Default TUI (no ``--config``): framework yaml → settings.json → project + patch, so WebUI's ``default_model`` / ``llm`` is what the first turn + actually runs. An explicit ``--config`` yaml still wins over settings. + """ + from ms_agent.config.resolver import ConfigResolver + from ms_agent.project.paths import global_home + + resolver = ConfigResolver( + global_dir=str(global_home()), + project_root=work_dir, + defaults=TUI_RESOLVER_DEFAULTS, + ) + agent_config = Config.from_task(config_path) if explicit_config else None + return resolver.resolve( + agent_config=agent_config, + project_path=work_dir, + ) + + @staticmethod + def _bind_todo_list_session(cfg, sess_dir: str) -> None: + """Point the builtin plan tool at this session dir. + + ``mcp: false`` must be set whenever plan_filename is written: a + ``tools.todo_list`` node without that flag is treated as an MCP server. + """ + plan_json = os.path.join(sess_dir, 'plan.json') + plan_md = os.path.join(sess_dir, 'plan.md') + OmegaConf.update(cfg, 'tools.todo_list.mcp', False, merge=True) + OmegaConf.update( + cfg, 'tools.todo_list.plan_filename', plan_json, merge=True) + OmegaConf.update( + cfg, 'tools.todo_list.plan_md_filename', plan_md, merge=True) + + @staticmethod + def _prepare_config(config, permission_mode, work_dir, project=None): OmegaConf.update(config, 'generation_config.stream', True, merge=True) OmegaConf.update( config, 'generation_config.stream_output', True, merge=True) @@ -160,11 +326,16 @@ def _prepare_config(config, permission_mode, work_dir): OmegaConf.update(config, 'session_log.enabled', True, merge=True) # Interactive lifecycle regardless of stdin detection. OmegaConf.update(config, 'interactive', True, merge=True) + # Same as WebUI: data-driven provider layer (credentials + protocol). + OmegaConf.update(config, 'llm.use_provider_router', True, merge=True) # max_chat_round bounds autonomous *steps*; under route A the counter # accumulates across interactive turns (and restores on resume), so a # small per-task value would cut a long chat short. Raise it high — the # user (not a round cap) ends an interactive session. OmegaConf.update(config, 'max_chat_round', 1000, merge=True) + # Seed before _apply_session writes plan paths, so a fresh TUI without + # a WebUI-seeded settings.json still does not MCP-connect todo_list. + OmegaConf.update(config, 'tools.todo_list.mcp', False, merge=True) if permission_mode: OmegaConf.update( config, 'permission.mode', permission_mode, merge=True) @@ -175,14 +346,47 @@ def _prepare_config(config, permission_mode, work_dir): if c != 'input_callback' ] OmegaConf.update(config, 'callbacks', cbs, merge=False) - # Merge the work-dir project patch (e.g. a persisted /model override). + # Merge a work-dir ``.ms_agent/config.yaml`` pin if one exists. + # ``/model`` no longer writes this file; skipped when resolve() already + # applied it. + if not getattr(config, '_project_patch_applied', False): + try: + from ms_agent.config.resolver import ConfigResolver + patch = ConfigResolver()._load_project_patch(work_dir) + if patch is not None: + config = OmegaConf.merge(config, patch) + except Exception: + logger.debug( + 'work-dir config patch merge skipped', exc_info=True) + TuiApp._apply_provider_credentials(config) + # Same files WebUI writes: settings.json personalization + project + # instruction. File-based AGENTS.md / PROFILE.md are read live. + if project is not None and getattr(project, 'instruction', ''): + OmegaConf.update( + config, + 'personalization.project_instruction', + project.instruction, + merge=True, + ) try: - from ms_agent.config.resolver import ConfigResolver - patch = ConfigResolver()._load_project_patch(work_dir) - if patch is not None: - config = OmegaConf.merge(config, patch) + from ms_agent.personalization.settings import PersonalizationSettings + loaded = PersonalizationSettings().load() + if loaded.global_instruction: + OmegaConf.update( + config, + 'personalization.global_instruction', + loaded.global_instruction, + merge=True, + ) except Exception: - logger.debug('work-dir config patch merge skipped', exc_info=True) + logger.debug('personalization settings merge skipped', exc_info=True) + if project is not None: + try: + from ms_agent.personalization.memory_apply import ( + apply_project_memory) + apply_project_memory(config, project) + except Exception: + logger.debug('project memory apply skipped', exc_info=True) return config # -- session commands (registered into the agent's router) -- @@ -265,16 +469,85 @@ def _apply_session(self, session, resume: bool = False) -> None: otherwise the legacy output_dir/tag history (``read_history``) would be loaded when the new SessionLog is still empty — replaying the previous session's messages. SessionLog is the single source of truth here. + + When the session has its own model, that model is applied to the live + agent. The global default is left alone, so a new chat still starts on + the last model the user chose, not on whichever old chat was open. """ sess_dir = str(self._sm.sessions_dir / session.id) cfg = self.agent.config OmegaConf.update(cfg, 'session_log.dir', sess_dir, merge=True) OmegaConf.update( cfg, 'session_log.session_key', session.session_key, merge=True) + # Same as WebUI: plan files live beside the session log, not a + # project-shared workspace plan.json. mcp:false is required so + # ToolManager does not treat todo_list as an MCP server. + self._bind_todo_list_session(cfg, sess_dir) + # prepare_tools() rebuilds TodoListTool from this config each + # run_loop; do not patch extra_tools here (those instances are + # discarded). self.config = cfg # keep the app reference in sync for banners/views self.session = session self.state.session_name = session.name self.agent.load_cache = resume + if session.model: + self._bind_live_model(session.model_provider, session.model) + else: + # No per-conversation model yet (a chat from before this was + # stored). Follow the default for this visit, and do not write it + # onto the session: we do not know which model it originally used. + provider, model = self._default_binding() + self._bind_live_model(provider, model) + + def _default_binding(self) -> tuple[str | None, str | None]: + """Provider and model a new conversation starts on. + + Reads the shared default, not the model currently on the agent. After + resuming an older chat the agent is on that chat's model; a new chat + must still start on the default. + """ + from ms_agent.config.model_settings import ( + ModelSettingsManager, strip_provider_model_prefix) + from ms_agent.project.paths import global_home + + raw = ModelSettingsManager(global_home()).get_default_model() or '' + if '/' in raw: + provider, model = raw.split('/', 1) + model = strip_provider_model_prefix(provider, model) or model + return provider or None, model or None + if raw: + provider = str( + OmegaConf.select(self.agent.config, 'llm.service', default='') + or '') or None + return provider, raw + model = str( + OmegaConf.select(self.agent.config, 'llm.model', default='') + or '') or None + provider = str( + OmegaConf.select(self.agent.config, 'llm.service', default='') + or '') or None + return provider, model + + def _open_conversation(self): + """Create a session stamped with the current global default.""" + provider, model = self._default_binding() + return self._sm.create(model=model, model_provider=provider) + + def _bind_live_model(self, provider: str | None, model: str | None) -> None: + """Point the live agent at a conversation's model. + + Does not write ``default_model``. The next ``run()`` builds the client + from this config. + """ + if not model: + return + cfg = self.agent.config + OmegaConf.update(cfg, 'llm.model', model, merge=True) + if provider: + OmegaConf.update(cfg, 'llm.service', provider, merge=True) + self._apply_provider_credentials(cfg, overwrite=True) + self.config = cfg + self.state.model = model def _resume_target(self, arg: str): sessions = self._sm.list() @@ -290,16 +563,16 @@ def _session_has_history(self, session) -> bool: except Exception: return True # on doubt, keep it - def _prune_empty_sessions(self) -> None: - """Delete sessions that never received a user turn (leftover empties - from prior launches), so ``/sessions`` stays meaningful.""" - for s in self._sm.list(): - self._prune_if_empty(s) - def _prune_if_empty(self, session) -> None: + # Only drop unused sessions this process created. A resumed WebUI + # chat with no user-role line must stay. + owned = getattr(self, '_owned_session_ids', set()) + if session.id not in owned: + return if not self._session_has_history(session): try: self._sm.delete(session.id) + owned.discard(session.id) except Exception: pass @@ -357,8 +630,7 @@ def _banner(self) -> None: info.add_column(style='blue', justify='right') info.add_column() info.add_row( - 'model', - f'{getattr(llm, "service", "?")}/{getattr(llm, "model", "?")}') + 'model', str(getattr(llm, 'model', '') or '').strip() or '?') info.add_row('tools', ', '.join(tools) or '[dim]none[/]') info.add_row('perm', f'[yellow]{self.permission_mode}[/]') info.add_row('dir', f'[dim]{wd}[/]') @@ -431,20 +703,45 @@ def _quiet_logs() -> None: async def _serve(self) -> None: self._banner() - self._prune_empty_sessions( - ) # clear leftover empties from prior launches - self.session = self._sm.create(model=self._model or None) + # Do not wipe empty sessions on startup: WebUI may have created a + # chat the user has not typed into yet. Empty leftovers from *this* + # TUI process are pruned when leaving the session (below). + self.session = self._open_conversation() + self._owned_session_ids.add(self.session.id) resume = False # a fresh session reads a prompt; a resumed one restores self.renderer.rule(f'session {self.session.id}', 'green') while True: self._pending_switch = None self._apply_session(self.session, resume=resume) try: - gen = await self.agent.run(None, stream=True) + query = self._queued_query + self._queued_query = None + gen = await self.agent.run(query, stream=True) async for _ in gen: pass except EOFError: break # Ctrl-D at the prompt exits + except asyncio.CancelledError: + task = asyncio.current_task() + quitting = bool( + getattr( + getattr(getattr(self, 'agent', None), 'runtime', None), + 'should_stop', + False)) + # A cancelled task (/quit teardown, or this task itself) leaves. + # Any other CancelledError stays in the session. + # Task.cancelling() exists on Python 3.11+. + cancelling = getattr(task, 'cancelling', None) + if callable(cancelling) and cancelling(): + raise + if quitting: + break + self.renderer.finalize() + self.console.print( + '[dim](turn failed — session still open, ' + '/model to switch, /quit to exit)[/]') + resume = True + continue except KeyboardInterrupt: # Ctrl-C interrupts the running turn but keeps the REPL alive. # Cap the turn with an assistant marker so the resume below @@ -463,16 +760,33 @@ async def _serve(self) -> None: resume = True continue except Exception as e: # noqa: BLE001 — surface, don't crash the REPL + # A leaked MCP cancel-scope error is teardown noise, not a + # request to leave. cleanup_tools already swallows it on + # /quit; if it still surfaces mid-turn, stay in the session. self.renderer.finalize() - logger.warning('TUI lifecycle error', exc_info=True) + logger.warning('TUI turn error', exc_info=True) + # run_loop already emitted ErrorRaised; the renderer drew the + # panel. Reprinting it and then `break` looked like a crash + # (two identical errors, then "bye"). Keep the REPL so the + # user can /model switch or try again. resume=True restores + # the sealed failed turn instead of resending it. + from ms_agent.llm.model_discovery import ( + CHAT_UNSUPPORTED, + MODEL_ID_REJECTED, + chat_failed_because_unsupported, + model_id_rejected, + ) + model = self.state.model or '' + err = str(e) + if chat_failed_because_unsupported(model, err): + self.console.print(CHAT_UNSUPPORTED) + if model_id_rejected(err): + self.console.print(MODEL_ID_REJECTED) self.console.print( - Panel( - f'[bold]{type(e).__name__}[/]: {e}', - title='[red]error[/]', - border_style='red', - subtitle='[dim]LOG_LEVEL=INFO for details[/]', - expand=False)) - break + '[dim](turn failed — session still open, ' + '/model to switch, /quit to exit)[/]') + resume = True + continue self._name_session_from_log() # Resolve a resume target against the live list before pruning. switch = self._pending_switch @@ -485,7 +799,8 @@ async def _serve(self) -> None: break # user quit kind = switch[0] if kind == 'new': - self.session = self._sm.create(model=self._model or None) + self.session = self._open_conversation() + self._owned_session_ids.add(self.session.id) resume = False self.renderer.rule(f'new session {self.session.id}', 'green') elif kind == 'resume': @@ -520,6 +835,7 @@ def main( work_dir: Optional[str] = None, emit_events: Optional[str] = None, mcp_server_file: Optional[str] = None, + explicit_config: bool = False, ) -> None: TuiApp( config_path, @@ -528,4 +844,5 @@ def main( trust_remote_code=trust_remote_code, work_dir=work_dir, emit_events=emit_events, - mcp_server_file=mcp_server_file).run() + mcp_server_file=mcp_server_file, + explicit_config=explicit_config).run() diff --git a/ms_agent/tui/input.py b/ms_agent/tui/input.py index 6806239b7..ad67b8afc 100644 --- a/ms_agent/tui/input.py +++ b/ms_agent/tui/input.py @@ -22,6 +22,35 @@ if TYPE_CHECKING: from ms_agent.command.router import CommandRouter +# Bracketed paste wraps the clipboard in these markers. If they leak into the +# line (the terminal was not in paste mode) they must not become part of the +# command. The caret form is how a cooked terminal echoes the same bytes. +_PASTE_MARKERS = ( + '\x1b[200~', + '\x1b[201~', + '^[[200~', + '^[[201~', +) + + +def split_prompt_lines(text: str) -> list: + """Split one paste into the lines the command loop should see. + + prompt_toolkit returns a multiline paste as a single string. Fed through + whole, ``/model`` treats the next line as part of the model id. A trailing + empty line (the paste ended on Enter) is not itself a command. + """ + cleaned = text or '' + for marker in _PASTE_MARKERS: + cleaned = cleaned.replace(marker, '') + cleaned = cleaned.replace('\r\n', '\n').replace('\r', '\n') + if '\n' not in cleaned: + return [cleaned] + lines = cleaned.split('\n') + if lines and lines[-1] == '': + lines.pop() + return lines or [''] + def slash_matches(router, text: str): """Yield ``(name, description)`` for /command completions of ``text``. @@ -49,11 +78,17 @@ def __init__(self, state: TuiState, router: Optional['CommandRouter'] = None, theme: Theme = DEFAULT_THEME, - console: object = None) -> None: + console: object = None, + renderer: object = None) -> None: self._state = state self._router = router self._theme = theme self._console = console # rich Console, for the pre-prompt divider + # RichEventSink. Released before prompt_toolkit draws, so Live's + # refresh thread is not still writing when raw mode starts. + self._renderer = renderer + # Lines left over from a multiline paste. Each becomes its own prompt. + self._queued: list = [] self._session = self._build_session() def _build_session(self): @@ -108,6 +143,7 @@ def get_completions(self, document, complete_event): history=InMemoryHistory(), completer=_SlashCompleter(), complete_while_typing=True, + # Up and down walk this history. bottom_toolbar=self._toolbar, placeholder=HTML( 'Type a message' @@ -116,6 +152,11 @@ def get_completions(self, document, complete_event): except Exception: return None + def note_model(self, model: str) -> None: + """Refresh the status-bar model after a command mutates it.""" + if model: + self._state.model = str(model) + def _toolbar(self): s = self._state parts = [] @@ -133,15 +174,33 @@ def _message(self): return HTML( f'{self._theme.prompt_symbol} ') + def _release_terminal(self) -> None: + renderer = self._renderer + if renderer is None: + return + release = getattr(renderer, 'release_terminal', None) + if release is not None: + release() + async def read_prompt(self, prompt: str = '❯ ') -> str: # The TUI owns its prompt presentation; the caller's ``prompt`` arg is # only a fallback hint for the non-interactive path. + # Live must be fully stopped before prompt_toolkit enables raw mode + # and bracketed paste. A queued paste line skips the divider, but + # still waits out a region left up by the turn that just finished. + self._release_terminal() + if self._queued: + return self._queued.pop(0) if self._session is not None and sys.stdin.isatty(): # A subtle full-width divider separates the input from the # conversation above (Claude Code style). if self._console is not None: from rich.rule import Rule self._console.print(Rule(style='grey30')) - return await self._session.prompt_async(self._message()) + text = await self._session.prompt_async(self._message()) + lines = split_prompt_lines(text) + if len(lines) > 1: + self._queued.extend(lines[1:]) + return lines[0] if lines else '' loop = asyncio.get_running_loop() return await loop.run_in_executor(None, input, prompt) diff --git a/ms_agent/tui/live_config.py b/ms_agent/tui/live_config.py new file mode 100644 index 000000000..9d0d1ac6b --- /dev/null +++ b/ms_agent/tui/live_config.py @@ -0,0 +1,411 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Apply ledger changes on the next user message, inside the same agent. + +TUI sets :attr:`LLMAgent._on_user_turn` to :func:`apply_live_config`. +WebUI leaves the attribute unset and keeps its own rebuild path. +""" +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from omegaconf import OmegaConf + +from ms_agent.llm.message_text import prepend_text +from ms_agent.utils import get_logger + +logger = get_logger() + +_SKILL_NOTICE = ( + '\n' + 'Skill inventory updated. The list in the system prompt is from ' + 'session start; skills_list is current. Do not mention this notice ' + 'to the user.\n' + '' +) + + +def _token(payload: Any) -> str: + return hashlib.sha1( + json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest() + + +def _work_dir(agent) -> str | None: + work = str(OmegaConf.select(agent.config, 'output_dir', default='') or '') + return work or None + + +def _install_llm(agent, rebuilt) -> None: + from ms_agent.command.builtin.config_cmds import _install_rebuilt_llm + target = agent.llm + if target is not None and _install_rebuilt_llm(target, rebuilt): + if agent.runtime is not None: + agent.runtime.llm = target + return + agent.llm = rebuilt + if agent.runtime is not None: + agent.runtime.llm = rebuilt + + +def _drop_live_llm(agent) -> None: + """Remove the client that was built with a key the config no longer has. + + The agent stays. Slash commands still read ``config`` off the placeholder. + Memory tools must not keep the old client either. + """ + from types import SimpleNamespace + model = str(OmegaConf.select(agent.config, 'llm.model', default='') or '') + stub = SimpleNamespace(config=agent.config, model=model, _setup_stub=True) + agent.llm = stub + runtime = getattr(agent, 'runtime', None) + if runtime is not None: + runtime.llm = stub + for orch in list(getattr(agent, 'memory_tools', None) or []): + set_llm = getattr(orch, 'set_llm', None) + if set_llm is None: + continue + try: + set_llm(None) + except Exception: + logger.debug('live llm release skipped', exc_info=True) + + +def _point_memory_at_llm(agent) -> None: + for orch in list(getattr(agent, 'memory_tools', None) or []): + set_llm = getattr(orch, 'set_llm', None) + if set_llm is not None and agent.llm is not None: + set_llm(agent.llm) + + +async def _sync_llm(agent) -> None: + from ms_agent.llm import LLM + from ms_agent.llm.credentials import client_rejected_missing_key + from ms_agent.llm.runtime_token import llm_runtime_token + if agent.llm is None: + return + token = llm_runtime_token(agent.config) + if getattr(agent, '_llm_fp', None) == token: + return + # A placeholder with no fingerprint is startup. The setup prompt owns it. + # A placeholder that already has a fingerprint was installed because the + # key was cleared; a later edit must be allowed to build a client again. + if (getattr(agent.llm, '_setup_stub', False) + and getattr(agent, '_llm_fp', None) is None): + return + try: + rebuilt = LLM.from_config(agent.config) + except Exception as exc: + if client_rejected_missing_key(exc): + _drop_live_llm(agent) + agent._llm_fp = token + return + logger.debug('live llm rebuild skipped', exc_info=True) + return + if rebuilt is None: + return + _install_llm(agent, rebuilt) + agent._llm_fp = token + _point_memory_at_llm(agent) + + +def _emit_once(agent, key: str, text: str) -> None: + seen = getattr(agent, '_live_notice_keys', None) + if seen is None: + seen = set() + agent._live_notice_keys = seen + if key in seen: + return + seen.add(key) + emit = getattr(agent, '_emit_user_notice', None) + if emit is not None: + emit(text) + return + logger.error(text) + + +def _note_unreadable_config(agent) -> None: + """One sentence per broken ledger file or unreadable skill directory.""" + from pathlib import Path + + from ms_agent.project.paths import global_home + + roots = [Path(global_home())] + work = _work_dir(agent) + if work: + roots.append(Path(work) / '.ms_agent') + names = ('mcp.json', 'skills.json', 'settings.json') + for root in roots: + for name in names: + path = root / name + if not path.is_file(): + continue + try: + json.loads(path.read_text(encoding='utf-8')) + except json.JSONDecodeError: + _emit_once( + agent, f'json:{path}', f'{name} 读不进去。请检查格式。') + except OSError: + continue + skills = root / 'skills' + if not skills.is_dir(): + continue + try: + next(skills.iterdir(), None) + except OSError: + _emit_once( + agent, f'skills:{skills}', + '技能目录读不了。请检查这个目录的权限。') + + +def _search_in_sync(tool, block: dict) -> bool: + engines = block.get('engines') + if engines and not isinstance(engines, str): + wanted = [str(item).lower().strip() for item in engines] + else: + wanted = [str(block.get('engine') or 'arxiv').lower().strip()] + live = [ + str(item).lower().strip() + for item in (getattr(tool, '_engine_types', None) or []) + ] + if live != wanted: + return False + keys = getattr(tool, '_api_keys', None) or {} + fields = { + 'tavily': 'tavily_api_key', + 'exa': 'exa_api_key', + 'serpapi': 'serpapi_api_key', + } + for engine in wanted: + field = fields.get(engine) + if field and field in block: + if str(block.get(field) or '') != str(keys.get(engine) or ''): + return False + return True + + +def _find_search_tool(agent): + manager = getattr(agent, 'tool_manager', None) + if manager is None: + return None + for tool in getattr(manager, 'extra_tools', None) or []: + if getattr(tool, 'SERVER_NAME', None) == 'web_search': + return tool + return None + + +async def _sync_search(agent) -> None: + from ms_agent.config.search_settings import SearchSettingsManager + from ms_agent.project.paths import global_home + + block = SearchSettingsManager(global_home()).raw_block() + token = _token(block) + if getattr(agent, '_search_fp', None) == token: + return + try: + OmegaConf.update(agent.config, 'tools.web_search', block, merge=True) + except Exception: + logger.debug('live search config update failed', exc_info=True) + return + tool = _find_search_tool(agent) + if tool is None or not getattr(tool, '_engines', None): + agent._search_fp = token + return + if getattr(agent, '_search_fp', None) is None and _search_in_sync(tool, block): + agent._search_fp = token + return + reload = getattr(tool, 'reload_from_config', None) + if reload is None: + agent._search_fp = token + return + try: + await reload() + except Exception: + logger.debug('live search reload failed', exc_info=True) + return + agent._search_fp = token + + +def _say_mcp_still_down(agent, names) -> None: + for name in names: + _emit_once(agent, f'mcp-down:{name}', f'MCP 服务器 {name} 还是连不上。') + + +def _announce_unreachable(agent, runtime) -> set[str]: + """One line per server this process already failed to connect. + + Reading the recorded error does not open a new connection. + """ + list_servers = getattr(runtime, 'list_servers', None) + if list_servers is None: + return set() + try: + states = list(list_servers()) + except Exception: + logger.debug('mcp skip notice skipped', exc_info=True) + return set() + from ms_agent.mcp.runtime import mcp_unreachable_notice + seen = getattr(agent, '_live_notice_keys', None) + announced: set[str] = set() + for state in states: + name = getattr(state, 'name', None) + if not name: + continue + if getattr(state, 'status', None) == 'connected' and seen is not None: + seen.discard(f'mcp-skip:{name}') + continue + if getattr(state, 'status', None) != 'error': + continue + if getattr(state, 'enabled', True) is False: + continue + reason = getattr(state, 'last_error', None) + if not reason: + continue + announced.add(name) + _emit_once(agent, f'mcp-skip:{name}', + mcp_unreachable_notice(name, str(reason))) + return announced + + +async def _sync_mcp(agent) -> None: + runtime = getattr(agent, 'mcp_runtime', None) + if runtime is None: + return + from ms_agent.config.mcp_schema import ResolvedMCPConfig + from ms_agent.project.paths import global_home + from ms_agent.tui.managed_config import resolve_mcp_config + raw = resolve_mcp_config( + str(global_home()), + _work_dir(agent), + getattr(agent, 'mcp_server_file', None), + ) or {} + servers = dict((raw.get('mcpServers') or {})) + plugin = getattr(agent, '_plugin_runtime', None) + loaded = getattr(getattr(plugin, 'load_result', None), 'mcp_servers', None) + if isinstance(loaded, dict): + for name, entry in loaded.items(): + servers.setdefault(name, entry) + token = _token(servers) + pending = set(getattr(agent, '_mcp_reconnect_names', None) or ()) + if getattr(agent, '_mcp_fp', None) == token and not pending: + return + try: + await runtime.apply_config(ResolvedMCPConfig(mcp_servers=servers)) + except Exception: + logger.debug('live mcp apply failed', exc_info=True) + announced = _announce_unreachable(agent, runtime) + if pending: + _say_mcp_still_down( + agent, [name for name in pending if name not in announced]) + agent._mcp_reconnect_names = set() + agent._mcp_fp = token + return + announced = _announce_unreachable(agent, runtime) + still = [] + for name in pending: + if name in announced: + continue + state = runtime.get_server(name) if hasattr(runtime, 'get_server') else None + if state is None or getattr(state, 'status', None) != 'connected': + still.append(name) + if pending: + agent._mcp_reconnect_names = set() + _say_mcp_still_down(agent, still) + agent._mcp_fp = token + + +def _sync_skills(agent, messages) -> None: + runtime = getattr(agent, '_skill_runtime', None) + skills = getattr(agent.config, 'skills', None) + if runtime is None or not skills: + return + from ms_agent.project.paths import global_home + from ms_agent.tui.managed_config import merge_skills_into_config + try: + merge_skills_into_config(agent.config, str(global_home()), + _work_dir(agent)) + changed = runtime.sync_with_config(agent.config.skills) + except Exception: + logger.debug('live skill sync failed', exc_info=True) + return + if not changed or not bool(getattr(skills, 'update_notice', False)): + return + if not messages or getattr(messages[-1], 'role', None) != 'user': + return + messages[-1].content = prepend_text(messages[-1].content, _SKILL_NOTICE) + + +def _unregister_memory(agent) -> None: + from ms_agent.memory.unified.memory_tool import MemoryTool + manager = getattr(agent, 'tool_manager', None) + if manager is not None: + doomed = [ + tool for tool in manager.extra_tools + if isinstance(tool, MemoryTool) + ] + manager.extra_tools = [ + tool for tool in manager.extra_tools if tool not in doomed + ] + index = getattr(manager, '_tool_index', None) or {} + for key, val in list(index.items()): + if val and val[0] in doomed: + index.pop(key, None) + agent.memory_tools.clear() + agent._memory_guidance = '' + + +async def _sync_memory(agent) -> None: + runtime = getattr(agent, 'runtime', None) + pending = bool(getattr(runtime, '_memory_pending', False)) + work = _work_dir(agent) + if not work: + return + from ms_agent.personalization.memory_apply import apply_project_memory + from ms_agent.project import ProjectManager + from ms_agent.project.paths import global_home + project = ProjectManager(base_dir=str(global_home())).find_by_path(work) + if project is None: + return + kind = apply_project_memory(agent.config, project) + want = kind == 'file' + have = bool(getattr(agent, 'memory_tools', None)) + if want == have: + if pending and runtime is not None: + runtime._memory_pending = False + return + if have: + _unregister_memory(agent) + try: + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + await SharedMemoryManager.close_matching(str(memory_dir(work))) + except Exception: + logger.debug('memory store close skipped', exc_info=True) + if want: + load = getattr(agent, 'load_memory', None) + if load is not None: + await load() + if pending: + if not getattr(agent, 'memory_tools', None): + _emit_once(agent, 'memory-start', '记忆没有启动。请检查记忆目录。') + if runtime is not None: + runtime._memory_pending = False + + +async def apply_live_config(agent, messages=None) -> None: + """Refresh skills, MCP, the model client, and memory tools. + + ``messages`` is the turn that is about to run. When it ends in a user + message, a skill change is prefixed there. Pass ``None`` before the + first ``create_messages`` so the frozen skill section includes edits + made at the opening prompt. + """ + try: + _note_unreadable_config(agent) + _sync_skills(agent, messages) + await _sync_mcp(agent) + await _sync_search(agent) + await _sync_llm(agent) + await _sync_memory(agent) + except Exception: + logger.debug('live config sync failed', exc_info=True) diff --git a/ms_agent/tui/renderer.py b/ms_agent/tui/renderer.py index d491b6550..e0f0ad3f6 100644 --- a/ms_agent/tui/renderer.py +++ b/ms_agent/tui/renderer.py @@ -16,7 +16,7 @@ import re import time from contextlib import contextmanager -from rich.console import Console +from rich.console import Console, Group from rich.markdown import Markdown from rich.markup import escape from rich.panel import Panel @@ -29,6 +29,23 @@ from ms_agent.tui.tool_view import tool_header, tool_summary from ms_agent.ui.events import AgentEvent +_UNAVAILABLE = 'UNAVAILABLE' + + +def _bold_unavailable(text: str) -> Text: + """Bold the word UNAVAILABLE. The rest of a command panel stays plain. + + Command text is not markup, so a ``[bold]`` tag would print literally. + """ + body = Text() + parts = re.split(f'({_UNAVAILABLE})', text or '') + for part in parts: + if part == _UNAVAILABLE: + body.append(part, style='bold') + else: + body.append(part) + return body + _KEY_LINE = re.compile(r'^\s*[\w.\-]+:(\s|$)') @@ -122,15 +139,58 @@ def _print(self, *args, **kwargs) -> None: self.console.print(*args, **kwargs) def finalize(self) -> None: - """Tear down any in-flight Live region (call on error / turn abort).""" - if self._live is not None: - try: - self._live.stop() - except Exception: - pass - self._live = None + """Stop any in-flight Live region (call on error / turn abort).""" + self.release_terminal() + + def release_terminal(self) -> None: + """Stop the streaming region and wait until it can no longer write. + + ``Live.stop`` only signals the refresh thread; it does not join it. + Joining here means the next owner of the terminal (the input prompt) + is the only writer. A reply that was still streaming is typeset once + below the title line already printed above the region. + """ + buf = self._content_buf self._content_buf = '' self._label_shown = False + terminal = bool(self.console.is_terminal) + self._stop_live() + if terminal and buf.strip(): + self.console.print(Markdown(buf)) + + def _stop_live(self) -> None: + live = self._live + if live is None: + return + self._live = None + # Grab the thread first: stop() drops the reference without joining. + thread = getattr(live, '_refresh_thread', None) + try: + live.stop() + except Exception: + pass + if thread is not None: + try: + thread.join() + except RuntimeError: + pass + + def _stream_window(self, text: str): + """The last screen of ``text``. + + Live's ``crop`` keeps the top of a renderable, so the renderable + itself has to be the tail. One screen (minus a row, so the refresh + does not scroll) is all that gets redrawn each frame. + """ + width = max(1, self.console.size.width) + height = max(1, self.console.size.height - 1) + lines = list(Text(text).wrap(self.console, width)) + tail = lines[-height:] if lines else [] + if not tail: + return Text('') + if len(tail) == 1: + return tail[0] + return Group(*tail) # ── assistant content (streamed) ─────────────────────────────────────── @@ -148,31 +208,31 @@ def _on_content_delta(self, ev) -> None: if self.console.is_terminal: if self._live is None: from rich.live import Live + # One screen, erased when the reply finishes. stdout stays + # ours so this thread cannot still be writing when the prompt + # switches the terminal into raw / bracketed-paste mode. self._live = Live( console=self.console, refresh_per_second=12, - vertical_overflow='visible') + transient=True, + vertical_overflow='crop', + redirect_stdout=False, + redirect_stderr=False) self._live.start() - # Stream as plain Text (tolerant of half-formed markdown); the - # final ContentEnd reflows once into formatted Markdown. - self._live.update(Text(self._content_buf)) + # Plain text while tokens are still arriving; ContentEnd typesets + # the whole reply once, after this region has stopped. + self._live.update(self._stream_window(self._content_buf)) else: # Non-terminal (piped / CI): write raw for clean, scriptable output. self.console.file.write(ev.text) self.console.file.flush() def _on_content_end(self, ev) -> None: - buf = self._content_buf - if self._live is not None: - renderable = Markdown(buf) if buf.strip() else Text('') - self._live.update(renderable) - self._live.stop() - self._live = None - elif not self.console.is_terminal: + terminal = bool(self.console.is_terminal) + self.release_terminal() + if not terminal: self.console.file.write('\n') self.console.file.flush() - self._content_buf = '' - self._label_shown = False self._last_kind = None # next turn starts fresh (input divider spaces it) # ── reasoning (collapsed dim block) ───────────────────────────────────── @@ -268,7 +328,7 @@ def _on_notice(self, ev) -> None: word_wrap=True, background_color='default') else: - body = Text(ev.text) + body = _bold_unavailable(ev.text) self._print(Panel(body, border_style='dim', expand=False)) return style = { diff --git a/requirements/framework.txt b/requirements/framework.txt index 575530bcb..0f7c30be0 100644 --- a/requirements/framework.txt +++ b/requirements/framework.txt @@ -5,7 +5,7 @@ httpx>=0.28.1 json5 markdown matplotlib -mcp<2 +mcp modelscope>=1.35.2 modelscope-hub>=0.4.0 numpy diff --git a/requirements/research.txt b/requirements/research.txt index 26ab623d5..67ce2d3fd 100644 --- a/requirements/research.txt +++ b/requirements/research.txt @@ -6,7 +6,7 @@ google-search-results gradio>=5.0.0 json5 markdown -mcp<2 +mcp modelscope openai pandas diff --git a/setup.py b/setup.py index a8701dbc7..c11c56f55 100644 --- a/setup.py +++ b/setup.py @@ -253,6 +253,7 @@ def run(self): # get_defaults() returns {} and cross-framework convert # silently degrades to a raw file copy. 'agent_hub/default_configs/**/*', + 'skills/**/*', 'webui/**/*', # Capability Gateway skill package (copied into ms_agent/skills at build). 'skills/**/*', diff --git a/tests/agent_hub/test_secrets.py b/tests/agent_hub/test_secrets.py index 3c72ddb89..523b8fb84 100644 --- a/tests/agent_hub/test_secrets.py +++ b/tests/agent_hub/test_secrets.py @@ -58,6 +58,10 @@ def test_every_vendor_prefix_is_redacted(self): ("huggingface", "hf_XyZ1a2B3c4D5e6F7g8H9"), ("npm", "npm_XyZ1a2B3c4D5e6F7g8H9"), ("shopify", "shpat_XyZ1a2B3c4D5e6F7g8H9"), + ("tavily", "tvly-THIS-IS-MY-SECRET"), + ("modelscope", "ms-9f3c7ab2e81d04a6"), + ("modelscope_uuid", + "ms-8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8"), ) for label, token in samples: with self.subTest(vendor=label): @@ -78,6 +82,24 @@ def test_bearer_header_in_curl(self): self.assertNotIn(SK_KEY, out) self.assertTrue(hits) + def test_tavily_and_modelscope_prefixes_alone(self): + """A Got: line may contain only the token, with no secret-shaped word.""" + for token in ("tvly-THIS-IS-MY-SECRET", "ms-9f3c7ab2e81d04a6"): + with self.subTest(token=token[:8]): + out, hits = redact_text(token) + self.assertNotIn(token, out) + self.assertIn("[REDACTED:api_key]", out) + self.assertTrue(hits) + + def test_ms_product_names_are_not_keys(self): + for text in ("ms-agent-skills", "ms-agent-webui-frontend", + "use ms-agent-align-e2e for the run", + "ms-agent_20260726_101010"): + with self.subTest(text=text): + out, hits = redact_text(text) + self.assertEqual(out, text) + self.assertEqual(hits, ()) + def test_sk_prefix_needs_a_left_boundary(self): """``sk-`` inside an ordinary word is not a credential.""" for text in ("task-tracking pipeline", "ping", @@ -154,6 +176,8 @@ class TestDocumentedPlaceholdersSurvive(unittest.TestCase): "See https://example.com/docs?api_key=&model=qwen3-max", # placeholder spellings "OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx", + "TAVILY_API_KEY=tvly-xxxxxxxxxxxxxxxx", + "MODELSCOPE_API_KEY=ms-xxxxxxxxxxxxxxxx", "api_key: sk-your-key-here", 'token = "changeme123456"', # data fields that are NOT credentials diff --git a/tests/cli/test_cli_entry.py b/tests/cli/test_cli_entry.py index 3acb6a41b..2e2884dbb 100644 --- a/tests/cli/test_cli_entry.py +++ b/tests/cli/test_cli_entry.py @@ -11,6 +11,8 @@ from ms_agent.cli.agent import AgentCMD from ms_agent.cli.cli import run_cmd +from ms_agent.cli.tui import TuiCMD +from ms_agent.cli.ui import UICMD def test_agent_upload_positional_path_is_rejected(monkeypatch, capsys): @@ -80,3 +82,49 @@ def test_non_agent_command_keeps_unknown_args(monkeypatch): run_cmd() assert dispatched.get('ok') + + +def test_bare_ms_agent_starts_tui(monkeypatch): + monkeypatch.setattr(sys, 'argv', ['ms-agent']) + dispatched = {} + monkeypatch.setattr( + TuiCMD, 'execute', lambda self: dispatched.setdefault('ok', True)) + + run_cmd() + assert dispatched.get('ok') + + +def test_bare_options_are_tui_options(monkeypatch): + monkeypatch.setattr(sys, 'argv', ['ms-agent', '--work-dir', '/tmp/work']) + seen = {} + + def _execute(self): + seen['work_dir'] = self.args.work_dir + + monkeypatch.setattr(TuiCMD, 'execute', _execute) + run_cmd() + assert seen['work_dir'] == '/tmp/work' + + +def test_explicit_ui_is_not_rewritten_to_tui(monkeypatch): + monkeypatch.setattr(sys, 'argv', ['ms-agent', 'ui']) + dispatched = {} + monkeypatch.setattr( + UICMD, 'execute', lambda self: dispatched.setdefault('ui', True)) + monkeypatch.setattr( + TuiCMD, 'execute', lambda self: dispatched.setdefault('tui', True)) + + run_cmd() + assert dispatched == {'ui': True} + + +def test_unknown_word_still_prints_help(monkeypatch): + monkeypatch.setattr(sys, 'argv', ['ms-agent', 'not-a-command']) + monkeypatch.setattr( + TuiCMD, 'execute', + lambda self: (_ for _ in ()).throw( + AssertionError('tui must not start'))) + + with pytest.raises(SystemExit) as exc_info: + run_cmd() + assert exc_info.value.code == 2 diff --git a/tests/command/test_builtin.py b/tests/command/test_builtin.py index 63da124b7..e432297c3 100644 --- a/tests/command/test_builtin.py +++ b/tests/command/test_builtin.py @@ -82,6 +82,11 @@ async def test_help_lists_commands(self, router): assert result.type == CommandResultType.MESSAGE assert '/stop' in result.content assert '/help' in result.content + assert '/mcp' in result.content + assert '/skills' in result.content + assert '/search' in result.content + assert '/instruction' in result.content + assert '/profile' in result.content @pytest.mark.asyncio async def test_help_alias(self, router): diff --git a/tests/command/test_instruction_cmds.py b/tests/command/test_instruction_cmds.py new file mode 100644 index 000000000..fab4ac57b --- /dev/null +++ b/tests/command/test_instruction_cmds.py @@ -0,0 +1,134 @@ +"""TUI /instruction and /profile write the same files WebUI settings use.""" +import pytest +from omegaconf import OmegaConf + +from ms_agent.command.builtin import register_builtin_commands +from ms_agent.command.router import CommandRouter +from ms_agent.command.types import CommandContext +from ms_agent.prompting import workspace_files as wf + + +def make_router(): + router = CommandRouter() + register_builtin_commands(router) + return router + + +def make_ctx(text, runtime=None): + router = make_router() + cmd, args = CommandRouter.parse_input(text) + return CommandContext( + raw_input=text, + command_name=cmd, + args=args, + source='cli', + runtime=runtime, + extra={'router': router}, + ) + + +def _runtime(work): + return type('R', (), { + 'config': OmegaConf.create({'output_dir': str(work)}), + })() + + +@pytest.fixture(autouse=True) +def isolate_home(tmp_path, monkeypatch): + home = tmp_path / 'home' + home.mkdir() + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + wf.reset_cache() + yield home + wf.reset_cache() + + +class TestInstructionCommand: + @pytest.mark.asyncio + async def test_show_empty(self, tmp_path): + runtime = _runtime(tmp_path / 'work') + (tmp_path / 'work').mkdir() + result = await make_router().dispatch(make_ctx('/instruction', runtime)) + assert 'Global' in result.content + assert 'Project' in result.content + assert '(empty)' in result.content + + @pytest.mark.asyncio + async def test_global_writes_agents_md(self, isolate_home): + result = await make_router().dispatch( + make_ctx('/instruction global Always answer in French.')) + assert 'saved' in result.content.lower() + text = (isolate_home / 'AGENTS.md').read_text(encoding='utf-8') + assert 'Always answer in French.' in text + assert text.lstrip().startswith('---') + shown = await make_router().dispatch(make_ctx('/instruction global')) + assert 'Always answer in French.' in shown.content + + @pytest.mark.asyncio + async def test_project_writes_private_slot_only(self, tmp_path): + work = tmp_path / 'repo' + work.mkdir() + root = work / 'AGENTS.md' + root.write_text('# team\nkeep me\n', encoding='utf-8') + result = await make_router().dispatch( + make_ctx('/instruction project Use FastAPI.', _runtime(work))) + assert 'saved' in result.content.lower() + private = work / '.ms_agent' / 'AGENTS.md' + assert private.read_text(encoding='utf-8').strip() == 'Use FastAPI.' + assert root.read_text(encoding='utf-8') == '# team\nkeep me\n' + + @pytest.mark.asyncio + async def test_project_clear(self, tmp_path): + work = tmp_path / 'repo' + work.mkdir() + await make_router().dispatch( + make_ctx('/instruction project hello', _runtime(work))) + result = await make_router().dispatch( + make_ctx('/instruction project clear', _runtime(work))) + assert 'cleared' in result.content.lower() + assert (work / '.ms_agent' / 'AGENTS.md').read_text( + encoding='utf-8').strip() == '' + + @pytest.mark.asyncio + async def test_project_requires_work_dir(self): + result = await make_router().dispatch( + make_ctx('/instruction project hello')) + assert 'work dir' in result.content.lower() + + @pytest.mark.asyncio + async def test_alias_ins(self, isolate_home): + result = await make_router().dispatch( + make_ctx('/ins global Be brief.')) + assert 'saved' in result.content.lower() + assert 'Be brief.' in (isolate_home / 'AGENTS.md').read_text('utf-8') + + +class TestProfileCommand: + @pytest.mark.asyncio + async def test_callme_and_about(self, isolate_home): + await make_router().dispatch(make_ctx('/profile callme Alice')) + result = await make_router().dispatch( + make_ctx('/profile about I work on agents.')) + assert 'saved' in result.content.lower() + text = (isolate_home / 'PROFILE.md').read_text(encoding='utf-8') + assert '- Call me: Alice' in text + assert 'I work on agents.' in text + shown = await make_router().dispatch(make_ctx('/profile')) + assert 'Alice' in shown.content + assert 'I work on agents.' in shown.content + + @pytest.mark.asyncio + async def test_callme_clear_keeps_about(self, isolate_home): + await make_router().dispatch(make_ctx('/profile callme Alice')) + await make_router().dispatch(make_ctx('/profile about researcher')) + await make_router().dispatch(make_ctx('/profile callme clear')) + call_me, about = wf.read_profile() + assert call_me == '' + assert about == 'researcher' + + @pytest.mark.asyncio + async def test_quoted_about(self, isolate_home): + await make_router().dispatch( + make_ctx('/profile about "line one and two"')) + _, about = wf.read_profile() + assert about == 'line one and two' diff --git a/tests/command/test_interactive_input.py b/tests/command/test_interactive_input.py index 334670362..2889b1a7e 100644 --- a/tests/command/test_interactive_input.py +++ b/tests/command/test_interactive_input.py @@ -37,6 +37,39 @@ async def test_plain_prompt_submitted(self): assert turn.action == 'submit' assert turn.text == 'research quantum computing' + @pytest.mark.asyncio + async def test_mutate_state_refreshes_status_model(self): + class Src: + def __init__(self): + self.model = 'old' + self._lines = iter(['/switch', 'task']) + + def note_model(self, model): + self.model = model + + async def read_prompt(self, prompt='>>> '): + return next(self._lines) + + class LLM: + model = 'old' + config = None + + runtime = type('RT', (), {'llm': LLM()})() + + async def switch(ctx): + ctx.runtime.llm.model = 'gpt-4o' + return CommandResult( + type=CommandResultType.MUTATE_STATE, content='Switched') + + router = CommandRouter() + router.register(CommandDef(name='switch', description='x'), switch) + src = Src() + session = InteractiveSession(router, input_source=src) + turn = await session.run_turn(runtime=runtime) + assert src.model == 'gpt-4o' + assert turn.action == 'submit' + assert turn.text == 'task' + @pytest.mark.asyncio async def test_info_command_then_prompt(self): # /help shows output and re-prompts; the next plain line is the task. @@ -122,6 +155,20 @@ async def submit(ctx): assert turn.action == 'submit' assert turn.text == 'expanded prompt' + @pytest.mark.asyncio + async def test_command_exception_stays_in_loop(self): + async def boom(ctx): + raise TypeError('__class__ assignment only supported for mutable types') + + router = CommandRouter() + router.register(CommandDef(name='boom', description='x'), boom) + session = InteractiveSession(router) + inputs = iter(['/boom', 'still here']) + with patch('builtins.input', lambda *a: next(inputs)): + turn = await session.run_turn() + assert turn.action == 'submit' + assert turn.text == 'still here' + def _make_agent(config=None): """Build an LLMAgent without running its heavy __init__.""" @@ -211,3 +258,203 @@ def test_interactive_auto_adds_when_not_listed(self): cbs = self._input_callbacks(agent) assert len(cbs) == 1 assert cbs[0]._session._router is agent._get_command_router() + + +class TestEnsureLlmReady: + def _agent(self): + from ms_agent.agent.runtime import Runtime + from ms_agent.command import CommandRouter, register_builtin_commands + agent = _make_agent({'llm': {'model': 'm'}}) + agent._interactive = True + agent._input_source = None + agent._event_sink = None + agent._pending_attachments = None + agent.runtime = Runtime(llm=None) + router = CommandRouter() + register_builtin_commands(router) + agent._get_command_router = lambda: router + agent._stub_llm_for_setup() + return agent + + @pytest.mark.asyncio + async def test_quit_sets_should_stop(self): + agent = self._agent() + + def boom(): + raise ValueError('No API key found for provider "modelscope"') + + agent.prepare_llm = boom + with patch('builtins.input', return_value='/quit'): + result = await agent._ensure_llm_ready('hello') + assert result is None + assert agent.runtime.should_stop is True + + @pytest.mark.asyncio + async def test_retries_after_prompt(self): + from types import SimpleNamespace + agent = self._agent() + n = {'i': 0} + + def maybe(): + n['i'] += 1 + if n['i'] < 2: + raise ValueError('No API key found for provider "modelscope"') + agent.llm = SimpleNamespace(config=agent.config, model='m') + + agent.prepare_llm = maybe + with patch('builtins.input', return_value='ping'): + result = await agent._ensure_llm_ready('first') + assert result == 'ping' + assert n['i'] == 2 + assert not getattr(agent.llm, '_setup_stub', False) + + @pytest.mark.asyncio + async def test_non_interactive_still_raises(self): + agent = self._agent() + agent._interactive = False + + def boom(): + raise ValueError('No API key found for provider "modelscope"') + + agent.prepare_llm = boom + with pytest.raises(ValueError, match='No API key found'): + await agent._ensure_llm_ready('hi') + + @pytest.mark.asyncio + async def test_key_command_retries_the_sentence_that_asked(self): + from types import SimpleNamespace + agent = self._agent() + attempts = {'n': 0} + + def maybe(): + attempts['n'] += 1 + if attempts['n'] < 2: + raise ValueError('No API key found for provider "modelscope"') + agent.llm = SimpleNamespace(config=agent.config, model='m') + + agent.prepare_llm = maybe + agent._llm_credentials_present = lambda: True + seen = [] + + class _Sink: + def emit(self, event): + seen.append(event.text) + + agent._event_sink = _Sink() + + def _input(prompt=''): + if attempts.get('reads', 0): + raise AssertionError('prompted for another line') + attempts['reads'] = 1 + return '/help' + + with patch('builtins.input', side_effect=_input): + result = await agent._ensure_llm_ready('hello') + assert result == 'hello' + assert attempts['n'] == 2 + assert seen[-1] == '继续发送未完成的消息。' + + def test_setup_notice_is_printed_once_per_process(self): + import ms_agent.agent.llm_agent as mod + mod._credential_notice_sent = False + agent = self._agent() + seen = [] + + class _Sink: + def emit(self, event): + seen.append(event.text) + + agent._event_sink = _Sink() + err = ValueError('No API key found for provider "modelscope"') + try: + agent._emit_credential_setup(err) + agent._emit_credential_setup(err) + assert len(seen) == 1 + assert seen[0] == ( + '还没有 modelscope 的密钥。\n\n' + ' /model provider key modelscope <密钥>') + assert 'No API key found' not in seen[0] + finally: + mod._credential_notice_sent = False + + @pytest.mark.asyncio + async def test_cleared_key_keeps_the_next_line_not_the_one_that_arrived(self): + # Same wait as a session that never had a key: the line that found + # no client is dropped, the setup notice is emitted, and the next + # submitted line is what will be sent. + from types import SimpleNamespace + from ms_agent.llm.utils import Message + agent = self._agent() + notices = [] + agent._emit_credential_setup = lambda exc: notices.append(str(exc)) + attempts = {'n': 0} + + def maybe(): + attempts['n'] += 1 + if attempts['n'] < 2: + raise ValueError( + 'No API key found for provider "modelscope"') + agent.llm = SimpleNamespace(config=agent.config, model='m') + + agent.prepare_llm = maybe + bound = {} + + class _Mem: + def set_llm(self, llm): + bound['llm'] = llm + + agent.memory_tools = [_Mem()] + messages = [ + Message(role='assistant', content='earlier'), + Message(role='user', content='hi'), + ] + with patch('builtins.input', return_value='ping'): + await agent._hold_turn_until_llm_ready(messages, 1) + assert [m.content for m in messages] == ['earlier', 'ping'] + assert notices and 'No API key found' in notices[0] + assert agent.runtime.should_stop is False + assert bound['llm'] is agent.llm + assert not getattr(agent.llm, '_setup_stub', False) + + @pytest.mark.asyncio + async def test_cleared_key_quit_drops_the_unsent_line(self): + from ms_agent.llm.utils import Message + agent = self._agent() + + def boom(): + raise ValueError('No API key found for provider "modelscope"') + + agent.prepare_llm = boom + messages = [ + Message(role='assistant', content='earlier'), + Message(role='user', content='hi'), + ] + with patch('builtins.input', return_value='/quit'): + await agent._hold_turn_until_llm_ready(messages, 1) + assert [m.content for m in messages] == ['earlier'] + assert agent.runtime.should_stop is True + + @pytest.mark.asyncio + async def test_hold_sends_the_arrived_line_when_a_client_builds(self): + from types import SimpleNamespace + from ms_agent.llm.utils import Message + agent = self._agent() + original = Message(role='user', content='hi') + messages = [Message(role='assistant', content='earlier'), original] + + def ok(): + agent.llm = SimpleNamespace(config=agent.config, model='m') + + agent.prepare_llm = ok + await agent._hold_turn_until_llm_ready(messages, 1) + assert messages[-1] is original + + @pytest.mark.asyncio + async def test_hold_leaves_a_live_turn_alone(self): + from types import SimpleNamespace + from ms_agent.llm.utils import Message + agent = self._agent() + agent.llm = SimpleNamespace(model='m') + messages = [Message(role='user', content='hi')] + await agent._hold_turn_until_llm_ready(messages, 0) + assert messages[0].content == 'hi' diff --git a/tests/command/test_memory_cmds.py b/tests/command/test_memory_cmds.py new file mode 100644 index 000000000..5d0873e69 --- /dev/null +++ b/tests/command/test_memory_cmds.py @@ -0,0 +1,184 @@ +"""TUI /memory writes PersonalizationSettings + project flags like WebUI.""" +import json +from types import SimpleNamespace + +import pytest +from omegaconf import OmegaConf + +from ms_agent.command.builtin import register_builtin_commands +from ms_agent.command.router import CommandRouter +from ms_agent.command.types import CommandContext +from ms_agent.personalization.memory_apply import apply_project_memory +from ms_agent.personalization.settings import PersonalizationSettings +from ms_agent.personalization.types import PersonalizationConfig +from ms_agent.project.manager import ProjectManager +from ms_agent.tui.app import TuiApp + + +def make_router(): + router = CommandRouter() + register_builtin_commands(router) + return router + + +def make_ctx(text, runtime=None): + cmd, args = CommandRouter.parse_input(text) + return CommandContext( + raw_input=text, + command_name=cmd, + args=args, + source='cli', + runtime=runtime, + extra={'router': make_router()}, + ) + + +class MockRuntime: + def __init__(self, work): + self.config = OmegaConf.create({'output_dir': str(work)}) + self.memory_tools = [] + + async def load_memory(self): + self.memory_tools.append('loaded') + + +@pytest.fixture(autouse=True) +def isolate_home(tmp_path, monkeypatch): + home = tmp_path / 'home' + home.mkdir() + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + return home + + +class TestMemoryCommand: + @pytest.mark.asyncio + async def test_global_default_persists(self, isolate_home): + result = await make_router().dispatch(make_ctx('/memory global on')) + assert 'Global memory default → on' in result.content + loaded = PersonalizationSettings().load() + assert loaded.memory_enabled is True + data = json.loads((isolate_home / 'settings.json').read_text()) + assert data['personalization']['memory_enabled'] is True + + @pytest.mark.asyncio + async def test_project_toggle_writes_meta_and_config( + self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + ProjectManager(base_dir=str(isolate_home)).open_folder(str(work)) + runtime = MockRuntime(work) + result = await make_router().dispatch( + make_ctx('/memory on', runtime)) + assert 'Project memory → on' in result.content + project = ProjectManager(base_dir=str(isolate_home)).find_by_path( + str(work)) + assert project.memory_enabled is True + assert runtime._memory_pending is True + assert OmegaConf.select( + runtime.config, 'memory.unified_memory', default=None) is None + assert '下一条消息生效' not in result.content + assert runtime.memory_tools == [] + + @pytest.mark.asyncio + async def test_project_toggle_sees_work_dir_on_agent_runtime( + self, tmp_path, isolate_home): + from types import SimpleNamespace + work = tmp_path / 'repo' + work.mkdir() + ProjectManager(base_dir=str(isolate_home)).open_folder(str(work)) + runtime = SimpleNamespace( + config=None, + llm=SimpleNamespace( + config=OmegaConf.create({'output_dir': str(work)})), + memory_tools=[], + ) + result = await make_router().dispatch( + make_ctx('/memory on', runtime)) + assert 'Project memory → on' in result.content + + @pytest.mark.asyncio + async def test_backend_without_scope_is_global_only( + self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + pm = ProjectManager(base_dir=str(isolate_home)) + project = pm.open_folder(str(work), memory_backend='file') + runtime = MockRuntime(work) + result = await make_router().dispatch( + make_ctx('/memory backend vector', runtime)) + assert 'Global memory backend default → vector' in result.content + assert 'this project is unchanged' in result.content + loaded = PersonalizationSettings().load() + assert loaded.memory_backend == 'vector' + still = pm.get(project.id) + assert still.memory_backend == 'file' + + @pytest.mark.asyncio + async def test_project_backend_does_not_rewrite_global( + self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + pm = ProjectManager(base_dir=str(isolate_home)) + project = pm.open_folder(str(work), memory_backend='file') + runtime = MockRuntime(work) + result = await make_router().dispatch( + make_ctx('/memory project backend vector', runtime)) + assert 'Project memory backend → vector' in result.content + loaded = PersonalizationSettings().load() + assert loaded.memory_backend != 'vector' + updated = pm.get(project.id) + assert updated.memory_backend == 'vector' + + @pytest.mark.asyncio + async def test_global_backend_alias(self, isolate_home): + result = await make_router().dispatch( + make_ctx('/memory global backend file')) + assert 'Global memory backend default → file' in result.content + loaded = PersonalizationSettings().load() + assert loaded.memory_backend == 'file' + + @pytest.mark.asyncio + async def test_vector_does_not_silent_file_fallback( + self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + pm = ProjectManager(base_dir=str(isolate_home)) + project = pm.open_folder(str(work)) + pm.update(project.id, memory_enabled=True, memory_backend='vector') + runtime = MockRuntime(work) + result = await make_router().dispatch( + make_ctx('/memory on', runtime)) + assert 'vector' in result.content.lower() + assert OmegaConf.select( + runtime.config, 'memory', default=None) is None + + +def test_apply_project_memory_file_node(): + cfg = OmegaConf.create({}) + project = SimpleNamespace( + id='abc', memory_enabled=True, memory_backend='file') + assert apply_project_memory(cfg, project) == 'file' + assert cfg.memory.unified_memory.user_id == 'abc' + + +def test_open_folder_inherits_global_memory_default(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + PersonalizationSettings().save( + PersonalizationConfig(memory_enabled=True, memory_backend='file')) + work = tmp_path / 'fresh' + work.mkdir() + project = TuiApp._open_project(str(work)) + assert project.memory_enabled is True + again = TuiApp._open_project(str(work)) + assert again.id == project.id + + +def test_prepare_config_injects_unified_memory(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + cfg = OmegaConf.create({}) + project = SimpleNamespace( + id='p1', instruction='', memory_enabled=True, memory_backend='file') + out = TuiApp._prepare_config(cfg, None, str(tmp_path / 'work'), project) + assert out.memory.unified_memory.storage.backend == 'file' diff --git a/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py index d7a9bee8b..26df334b4 100644 --- a/tests/command/test_new_cmds.py +++ b/tests/command/test_new_cmds.py @@ -1,4 +1,5 @@ """Tests for new builtin commands: /usage, /model, /config, /quit, /tools, /compact, /context.""" +import json import pytest from dataclasses import dataclass, field from typing import List @@ -49,6 +50,20 @@ def make_router(): return router +def _keyed(provider: str) -> None: + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + ModelSettingsManager(global_home()).patch_provider( + provider, api_key='sk-test', base_url='https://example.invalid/v1') + + +def _catalog_ok(monkeypatch, ids): + from ms_agent.llm.model_discovery import CatalogProbe + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', + lambda *_a, **_k: CatalogProbe('ok', 200, list(ids))) + + def make_ctx(text, runtime=None, messages=None): router = make_router() cmd, args = CommandRouter.parse_input(text) @@ -125,6 +140,18 @@ async def test_alias_stats(self): class TestModel: + @pytest.fixture(autouse=True) + def _isolate_home(self, tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path / 'ms_home')) + for name in ( + 'OPENAI_API_KEY', + 'DASHSCOPE_API_KEY', + 'ANTHROPIC_API_KEY', + 'MODELSCOPE_API_KEY', + 'MINIMAX_API_KEY', + ): + monkeypatch.delenv(name, raising=False) + @pytest.mark.asyncio async def test_show_current_model(self): router = make_router() @@ -134,17 +161,212 @@ async def test_show_current_model(self): assert 'openai' in result.content @pytest.mark.asyncio - async def test_switch_model(self): + async def test_switch_model(self, monkeypatch): runtime = MockRuntime() router = make_router() - ctx = make_ctx('/model gpt-4o', runtime=runtime) - result = await router.dispatch(ctx) + _keyed('openai') + _catalog_ok(monkeypatch, ['gpt-4o']) + result = await router.dispatch( + make_ctx('/model gpt-4o', runtime=runtime)) assert result.type == CommandResultType.MUTATE_STATE assert 'gpt-4o' in result.content assert runtime.llm.model == 'gpt-4o' @pytest.mark.asyncio - async def test_switch_model_persists_to_project_patch(self, tmp_path): + async def test_slash_in_model_id_stays_on_current_provider(self, monkeypatch): + runtime = MockRuntime() + router = make_router() + _keyed('openai') + _catalog_ok(monkeypatch, ['MiniMax/MiniMax-M2.1']) + result = await router.dispatch( + make_ctx('/model MiniMax/MiniMax-M2.1', runtime=runtime)) + assert result.type == CommandResultType.MUTATE_STATE + assert runtime.llm.model == 'MiniMax/MiniMax-M2.1' + assert runtime.llm.config.llm.service == 'openai' + assert 'Provider: openai' in result.content + assert 'Model: MiniMax/MiniMax-M2.1' in result.content + + @pytest.mark.asyncio + async def test_known_provider_then_slashy_model(self, monkeypatch): + runtime = MockRuntime() + router = make_router() + _keyed('dashscope') + _catalog_ok(monkeypatch, ['MiniMax/MiniMax-M2.1']) + result = await router.dispatch( + make_ctx( + '/model dashscope/MiniMax/MiniMax-M2.1', runtime=runtime)) + assert runtime.llm.config.llm.service == 'dashscope' + assert runtime.llm.model == 'MiniMax/MiniMax-M2.1' + assert 'Provider: dashscope' in result.content + assert 'Switch: /model dashscope MiniMax/MiniMax-M2.1' in result.content + + @pytest.mark.asyncio + async def test_space_form_sets_provider_when_model_has_slash( + self, monkeypatch): + runtime = MockRuntime() + _keyed('dashscope') + _catalog_ok(monkeypatch, ['MiniMax/MiniMax-M2.1']) + result = await make_router().dispatch( + make_ctx( + '/model dashscope MiniMax/MiniMax-M2.1', runtime=runtime)) + assert runtime.llm.config.llm.service == 'dashscope' + assert runtime.llm.model == 'MiniMax/MiniMax-M2.1' + + @pytest.mark.asyncio + async def test_switch_provider_without_key_rolls_back(self, monkeypatch): + monkeypatch.delenv('MINIMAX_API_KEY', raising=False) + runtime = MockRuntime() + result = await make_router().dispatch( + make_ctx('/model minimax/MiniMax-M2.1', runtime=runtime)) + assert result.type == CommandResultType.MESSAGE + assert runtime.llm.config.llm.service == 'openai' + assert runtime.llm.model == 'qwen3.7-plus' + assert 'Cannot switch to:' in result.content + assert 'Provider: minimax' in result.content + assert 'Model: MiniMax-M2.1' in result.content + assert 'Still on:' in result.content + assert 'Provider: openai' in result.content + assert 'Model: qwen3.7-plus' in result.content + assert 'Set a key: /model provider key minimax ' in result.content + assert 'Staying on' not in result.content + assert 'keep that provider:\n /model minimax MiniMax-M2.1' not in result.content + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + stored = ModelSettingsManager(global_home()).get_default_model() + assert stored != 'minimax/MiniMax-M2.1' + + @pytest.mark.asyncio + async def test_switch_fail_hints_keyed_provider(self, monkeypatch): + for env in ( + 'MINIMAX_API_KEY', + 'OPENAI_API_KEY', + 'DASHSCOPE_API_KEY', + 'ANTHROPIC_API_KEY', + 'MODELSCOPE_API_KEY', + 'GOOGLE_API_KEY', + 'GEMINI_API_KEY', + 'KIMI_API_KEY', + 'MOONSHOT_API_KEY', + 'DEEPSEEK_API_KEY', + 'OPENROUTER_API_KEY', + 'GLM_API_KEY', + 'ZHIPU_API_KEY', + 'ZHIPUAI_API_KEY', + ): + monkeypatch.delenv(env, raising=False) + from ms_agent.llm.model_discovery import CatalogProbe + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', + lambda *_a, **_k: CatalogProbe('no_catalog', 404, [])) + runtime = MockRuntime() + router = make_router() + await router.dispatch( + make_ctx( + '/model provider add acme key=sk url=https://acme/v1 protocol=openai', + runtime=runtime)) + result = await router.dispatch( + make_ctx('/model minimax/MiniMax-M2.1', runtime=runtime)) + assert 'Or switch to a keyed provider (acme):' in result.content + assert '/model acme ' in result.content + assert '{provider_id}' not in result.content + assert 'keep that provider' not in result.content + + @pytest.mark.asyncio + async def test_same_provider_without_key_does_not_repeat_command( + self, monkeypatch): + monkeypatch.delenv('MINIMAX_API_KEY', raising=False) + runtime = MockRuntime() + runtime.llm.model = 'minimax MiniMax-M2.1' + runtime.llm.config.llm.service = 'minimax' + runtime.llm.config.llm.model = 'minimax MiniMax-M2.1' + result = await make_router().dispatch( + make_ctx('/model minimax MiniMax-M2.1', runtime=runtime)) + assert result.type == CommandResultType.MESSAGE + assert runtime.llm.config.llm.service == 'minimax' + assert runtime.llm.model == 'MiniMax-M2.1' + assert runtime.llm.config.llm.model == 'MiniMax-M2.1' + assert 'Already on:' in result.content + assert 'Provider: minimax' in result.content + assert 'Model: MiniMax-M2.1' in result.content + assert 'minimax MiniMax-M2.1' not in result.content + assert 'Staying on' not in result.content + assert '/model minimax MiniMax-M2.1' not in result.content + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + stored = ModelSettingsManager(global_home()).get_default_model() + assert stored != 'minimax/minimax MiniMax-M2.1' + + @pytest.mark.asyncio + async def test_show_strips_glued_provider_prefix(self, tmp_path): + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + home = tmp_path / 'ms_home' + home.mkdir(parents=True, exist_ok=True) + (home / 'settings.json').write_text(json.dumps({ + 'default_model': 'minimax/minimax MiniMax-M2.1', + 'llm': { + 'provider': 'minimax', + 'model': 'minimax MiniMax-M2.1', + }, + })) + runtime = MockRuntime() + runtime.llm.model = 'minimax MiniMax-M2.1' + runtime.llm.config.llm.service = 'minimax' + runtime.llm.config.llm.model = 'minimax MiniMax-M2.1' + result = await make_router().dispatch( + make_ctx('/model', runtime=runtime)) + assert 'Provider: minimax' in result.content + assert 'Model: MiniMax-M2.1' in result.content + assert 'minimax MiniMax-M2.1' not in result.content + assert runtime.llm.model == 'MiniMax-M2.1' + stored = ModelSettingsManager(global_home()).get_default_model() + assert stored == 'minimax/MiniMax-M2.1' + + @pytest.mark.asyncio + async def test_show_warns_when_live_client_differs(self): + runtime = MockRuntime() + runtime.llm.model = 'MiniMax-M2.1' + runtime.llm.config.llm.service = 'minimax' + runtime.llm.spec = type('S', (), {'name': 'dashscope'})() + runtime.llm.transport = type( + 'T', (), {'base_url': 'https://dashscope.example/v1'})() + result = await make_router().dispatch( + make_ctx('/model', runtime=runtime)) + assert 'WARNING' in result.content + assert 'dashscope' in result.content + assert 'https://dashscope.example/v1' in result.content + + @pytest.mark.asyncio + async def test_show_current_separates_provider_and_model(self): + runtime = MockRuntime() + runtime.llm.model = 'MiniMax/MiniMax-M2.1' + runtime.llm.config.llm.service = 'dashscope' + result = await make_router().dispatch( + make_ctx('/model', runtime=runtime)) + assert 'Provider: dashscope' in result.content + assert 'Model: MiniMax/MiniMax-M2.1' in result.content + assert 'Switch: /model dashscope MiniMax/MiniMax-M2.1' in result.content + + @pytest.mark.asyncio + async def test_switch_model_replaces_setup_stub(self, monkeypatch): + from types import SimpleNamespace + + config = _make_mock_config() + stub = SimpleNamespace(config=config, model='old', _setup_stub=True) + runtime = MockRuntime(llm=stub) + router = make_router() + ctx = make_ctx('/model dashscope/qwen3.8-flash', runtime=runtime) + _keyed('dashscope') + _catalog_ok(monkeypatch, ['qwen3.8-flash']) + result = await router.dispatch(ctx) + assert result.type == CommandResultType.MUTATE_STATE + assert 'qwen3.8-flash' in result.content + assert runtime.llm.model == 'qwen3.8-flash' + assert config.llm.service == 'dashscope' + + @pytest.mark.asyncio + async def test_switch_model_persists_to_settings_not_project_patch( + self, tmp_path, monkeypatch): # The committed source YAML must never be mutated by /model. yaml_text = ( 'llm:\n' @@ -158,35 +380,404 @@ async def test_switch_model_persists_to_project_patch(self, tmp_path): config = OmegaConf.create({ 'llm': {'service': 'openai', 'model': 'qwen3.5-plus'}, 'local_dir': str(tmp_path), + 'output_dir': str(tmp_path), 'name': 'searcher.yaml', }) runtime = MockRuntime(llm=MockLLM(model='qwen3.5-plus', config=config)) router = make_router() ctx = make_ctx('/model qwen3.7-max', runtime=runtime) + _keyed('openai') + _catalog_ok(monkeypatch, ['qwen3.7-max']) result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE - assert 'Saved to' in result.content + assert 'qwen3.7-max' in result.content + assert 'Saved as the default' not in result.content + assert '下一条消息使用它' not in result.content + assert 'project patch' not in result.content.lower() # The source YAML is untouched. assert cfg_file.read_text(encoding='utf-8') == yaml_text - # The override landed in the project patch, which from_task merges back. - # Writer now targets the new .ms_agent/ dir (resolver reads both). + # /model writes the WebUI-shared default only — not a folder pin that + # would later hide a WebUI default change. patch_file = tmp_path / '.ms_agent' / 'config.yaml' - assert patch_file.exists() - patch_cfg = OmegaConf.load(str(patch_file)) - assert patch_cfg.llm.model == 'qwen3.7-max' + assert not patch_file.exists() + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + assert ModelSettingsManager(global_home()).get_default_model() == ( + 'openai/qwen3.7-max') @pytest.mark.asyncio - async def test_switch_model_no_source_file(self): - # config without local_dir/name -> in-memory only, no crash + async def test_switch_model_no_source_file(self, tmp_path, monkeypatch): + # Still writes the WebUI-shared default_model in settings.json. runtime = MockRuntime() router = make_router() ctx = make_ctx('/model gpt-4o', runtime=runtime) + _keyed('openai') + _catalog_ok(monkeypatch, ['gpt-4o']) result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE - assert 'in-memory only' in result.content + assert 'gpt-4o' in result.content + assert 'Saved as the default' not in result.content + assert '下一条消息使用它' not in result.content + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + assert ModelSettingsManager(global_home()).get_default_model() == 'openai/gpt-4o' + + @pytest.mark.asyncio + async def test_switch_updates_this_session_and_the_default( + self, tmp_path, monkeypatch): + """Switching changes the shared default and this conversation. + + Another conversation's stored model stays put, so coming back to it + does not follow the new default. + """ + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project import SessionManager + from ms_agent.project.manager import ProjectManager + from ms_agent.tui.app import TuiApp + + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'repo' + work.mkdir() + project = TuiApp._open_project(str(work)) + sm = SessionManager(project) + current = sm.create(model='qwen3.7-plus', model_provider='openai') + other = sm.create(model='kept-model', model_provider='openai') + config = OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'qwen3.7-plus', + 'openai_api_key': 'sk-test', + }, + 'session_log': { + 'dir': str(sm.sessions_dir / current.id), + }, + }) + runtime = MockRuntime(llm=MockLLM(model='qwen3.7-plus', config=config)) + _keyed('openai') + _catalog_ok(monkeypatch, ['gpt-4o']) + result = await make_router().dispatch( + make_ctx('/model gpt-4o', runtime=runtime)) + assert result.type == CommandResultType.MUTATE_STATE + assert ModelSettingsManager(home).get_default_model() == 'openai/gpt-4o' + stored = sm.get(current.id) + assert stored.model == 'gpt-4o' + assert stored.model_provider == 'openai' + untouched = sm.get(other.id) + assert untouched.model == 'kept-model' + assert ProjectManager(str(home)).get(project.id) is not None + + @pytest.mark.asyncio + async def test_model_list_reads_settings(self, tmp_path): + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + ModelSettingsManager(global_home()).set_default_model( + 'a-1', provider='acme') + router = make_router() + ctx = make_ctx('/model list', runtime=MockRuntime()) + result = await router.dispatch(ctx) + assert 'Default: acme/a-1' in result.content + assert 'Providers:' in result.content + assert 'WebUI' not in result.content + + @pytest.mark.asyncio + async def test_model_list_does_not_fetch(self, monkeypatch): + called = [] + + def boom(*_a, **_k): + called.append(1) + return [] + + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.fetch_model_ids', boom) + await make_router().dispatch( + make_ctx('/model list', runtime=MockRuntime())) + assert called == [] + + @pytest.mark.asyncio + async def test_model_list_live_filters_and_does_not_persist( + self, tmp_path, monkeypatch): + for env in ( + 'DASHSCOPE_API_KEY', + 'MODELSCOPE_API_KEY', + 'OPENAI_API_KEY', + ): + monkeypatch.delenv(env, raising=False) + from ms_agent.llm.model_discovery import CatalogProbe + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', + lambda *_a, **_k: CatalogProbe('no_catalog', 404, [])) + runtime = MockRuntime() + router = make_router() + await router.dispatch( + make_ctx( + '/model provider add acme key=sk-secret url=https://acme/v1 protocol=openai', + runtime=runtime)) + await router.dispatch( + make_ctx('/model catalog add acme catalog-only', runtime=runtime)) + + def fake(url, protocol, api_key): + assert url == 'https://acme/v1' + assert api_key == 'sk-secret' + return [ + 'qwen-plus', + 'qwen-vl-max', + 'wanx-v1', + 'text-embedding-v3', + ] + + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.fetch_model_ids', fake) + result = await router.dispatch( + make_ctx('/model list live acme', runtime=runtime)) + assert 'preview only' in result.content + assert 'catalog: catalog-only' in result.content + assert 'live: 2 chat' in result.content + assert ' qwen-plus' in result.content + assert ' qwen-vl-max' in result.content + assert 'wanx-v1' not in result.content + assert 'text-embedding-v3' not in result.content + assert 'dropped' not in result.content + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['acme']['models'] == ['catalog-only'] + + skipped = await router.dispatch( + make_ctx('/model list live openai', runtime=runtime)) + assert 'skipped (no API key)' in skipped.content + + @pytest.mark.asyncio + async def test_provider_add_set_key_catalog(self, tmp_path, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', + lambda *_a, **_k: CatalogProbe('no_catalog', 404, [])) + runtime = MockRuntime() + router = make_router() + added = await router.dispatch( + make_ctx( + '/model provider add acme key=sk-secret url=https://acme/v1 protocol=openai', + runtime=runtime)) + assert 'Provider acme saved' in added.content + assert 'Switch with /model acme ' in added.content + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['acme']['api_key'] == 'sk-secret' + assert data['providers']['acme']['base_url'] == 'https://acme/v1' + listed = await router.dispatch(make_ctx('/model list', runtime=runtime)) + assert 'sk-secret' not in listed.content + assert 'key=sk-s****cret' in listed.content + await router.dispatch( + make_ctx('/model provider key acme sk-new', runtime=runtime)) + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['acme']['api_key'] == 'sk-new' + await router.dispatch( + make_ctx('/model catalog add acme a-1', runtime=runtime)) + await router.dispatch( + make_ctx('/model catalog remove acme a-1', runtime=runtime)) + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert 'a-1' not in data['providers']['acme'].get('models', []) + await router.dispatch( + make_ctx('/model provider remove acme', runtime=runtime)) + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert 'acme' not in data.get('providers', {}) + + @pytest.mark.asyncio + async def test_catalog_drop_uses_current_provider(self, tmp_path): + runtime = MockRuntime() + runtime.llm.config.llm.service = 'acme' + router = make_router() + await router.dispatch( + make_ctx( + '/model provider add acme key=sk url=https://acme/v1 protocol=openai', + runtime=runtime)) + await router.dispatch( + make_ctx('/model catalog add acme qwen3.9-flash', runtime=runtime)) + dropped = await router.dispatch( + make_ctx('/model catalog drop qwen3.9-flash', runtime=runtime)) + assert dropped.content == ( + 'Removed qwen3.9-flash from acme catalog (current provider).') + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert 'qwen3.9-flash' not in data['providers']['acme'].get('models', []) + + @pytest.mark.asyncio + async def test_catalog_unknown_action_does_not_dump_full_usage(self): + result = await make_router().dispatch( + make_ctx('/model catalog foo bar', runtime=MockRuntime())) + assert 'Unknown catalog action' in result.content + assert 'Need: /model catalog' in result.content + assert '/model provider add' not in result.content + + @pytest.mark.asyncio + async def test_catalog_remove_model_only_is_focused_without_override(self): + result = await make_router().dispatch( + make_ctx( + '/model catalog remove qwen3.9-flash', runtime=MockRuntime())) + assert 'Need: /model catalog remove ' in result.content + assert '/model provider add' not in result.content + + @pytest.mark.asyncio + async def test_catalog_remove_unknown_model_does_not_claim_success( + self, tmp_path): + runtime = MockRuntime() + runtime.llm.config.llm.service = 'acme' + router = make_router() + await router.dispatch( + make_ctx( + '/model provider add acme key=sk url=https://acme/v1 protocol=openai', + runtime=runtime)) + await router.dispatch( + make_ctx('/model catalog add acme qwen3.8-flash', runtime=runtime)) + result = await router.dispatch( + make_ctx( + '/model catalog remove qwen3.9-flash12', runtime=runtime)) + assert 'not in the acme catalog (current provider)' in result.content + assert 'Pinned: qwen3.8-flash' in result.content + assert 'Removed' not in result.content + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['acme']['models'] == ['qwen3.8-flash'] + + @pytest.mark.asyncio + async def test_catalog_no_args_lists_pinned(self, tmp_path): + runtime = MockRuntime() + router = make_router() + await router.dispatch( + make_ctx( + '/model provider add acme key=sk url=https://acme/v1 protocol=openai', + runtime=runtime)) + await router.dispatch( + make_ctx('/model catalog add acme qwen3.8-flash', runtime=runtime)) + result = await router.dispatch( + make_ctx('/model catalog', runtime=runtime)) + assert 'Pinned catalogs' in result.content + assert 'acme: qwen3.8-flash' in result.content + assert 'Need: /model catalog add|remove' in result.content + + @pytest.mark.asyncio + async def test_provider_key_missing_args_is_focused(self): + result = await make_router().dispatch( + make_ctx('/model provider key', runtime=MockRuntime())) + assert 'Need: /model provider key |clear' in result.content + assert 'Got: /model provider key' in result.content + assert '/model catalog add' not in result.content + + @pytest.mark.asyncio + async def test_help_usage_example_is_dashscope_qwen(self): + result = await make_router().dispatch( + make_ctx('/model help', runtime=MockRuntime())) + assert 'Example: /model dashscope qwen3.8-flash' in result.content + assert 'e.g. dashscope' in result.content + assert 'e.g. qwen3.8-flash' in result.content + assert 'Saved in ' in result.content + assert 'settings.json' in result.content + assert 'WebUI' not in result.content + assert 'MS_AGENT_HOME' not in result.content + assert '' in result.content + assert 'MiniMax' not in result.content + assert '~/.ms_agent' not in result.content + assert '' not in result.content + + @pytest.mark.asyncio + async def test_list_unknown_option_is_focused(self): + result = await make_router().dispatch( + make_ctx('/model list foo', runtime=MockRuntime())) + assert 'Need: /model list live [provider]' in result.content + assert '/model provider add' not in result.content + + @pytest.mark.asyncio + async def test_unknown_provider_action_is_focused(self): + result = await make_router().dispatch( + make_ctx('/model provider foo bar', runtime=MockRuntime())) + assert "Unknown provider action 'foo'" in result.content + assert 'Need: /model provider' in result.content + assert '/model catalog add' not in result.content + + @pytest.mark.asyncio + async def test_cannot_remove_builtin_without_override(self): + result = await make_router().dispatch( + make_ctx('/model provider remove openai', runtime=MockRuntime())) + assert 'Cannot remove builtin' in result.content + + @pytest.mark.asyncio + async def test_unknown_provider_prefix_is_rejected(self, tmp_path): + runtime = MockRuntime() + result = await make_router().dispatch( + make_ctx('/model dashcope/qwen-max', runtime=runtime)) + assert result.type == CommandResultType.MESSAGE + assert 'No provider named dashcope' in result.content + assert 'Known:' in result.content + assert runtime.llm.model == 'qwen3.7-plus' + assert runtime.llm.config.llm.model == 'qwen3.7-plus' + settings = tmp_path / 'ms_home' / 'settings.json' + if settings.exists(): + assert 'dashcope' not in settings.read_text() + + @pytest.mark.asyncio + async def test_capitalized_provider_typo_is_rejected(self, tmp_path): + runtime = MockRuntime() + result = await make_router().dispatch( + make_ctx('/model Dashcope/qwen-max', runtime=runtime)) + assert 'No provider named Dashcope' in result.content + assert runtime.llm.config.llm.model == 'qwen3.7-plus' + settings = tmp_path / 'ms_home' / 'settings.json' + if settings.exists(): + assert 'Dashcope' not in settings.read_text() + + @pytest.mark.asyncio + async def test_runtime_only_key_still_checks_the_catalog(self, monkeypatch): + monkeypatch.delenv('OPENAI_API_KEY', raising=False) + _catalog_ok(monkeypatch, ['gpt-4o']) + runtime = MockRuntime() + result = await make_router().dispatch( + make_ctx('/model not-a-real-model', runtime=runtime)) + assert '列表里没有 not-a-real-model' in result.content + assert runtime.llm.config.llm.model == 'qwen3.7-plus' + + @pytest.mark.asyncio + async def test_runtime_only_key_notes_a_missing_catalog(self, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + monkeypatch.delenv('OPENAI_API_KEY', raising=False) + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', + lambda *_a, **_k: CatalogProbe('no_catalog', 404, [])) + runtime = MockRuntime() + result = await make_router().dispatch( + make_ctx('/model some-model', runtime=runtime)) + assert '没有返回模型列表' in result.content + assert 'Switched to' in result.content + + @pytest.mark.asyncio + async def test_catalog_add_rejects_secret_shaped_model(self, tmp_path): + runtime = MockRuntime() + secret = 'sk-THIS-IS-MY-SECRET' + result = await make_router().dispatch( + make_ctx(f'/model catalog add {secret}', runtime=runtime)) + assert 'API key' in result.content + assert secret not in result.content + assert 'REDACTED' in result.content + settings = tmp_path / 'ms_home' / 'settings.json' + if settings.exists(): + assert secret not in settings.read_text() + + @pytest.mark.asyncio + async def test_switch_rejects_secret_shaped_model(self): + runtime = MockRuntime() + secret = 'sk-THIS-IS-MY-SECRET' + result = await make_router().dispatch( + make_ctx(f'/model {secret}', runtime=runtime)) + assert 'API key' in result.content + assert secret not in result.content + assert runtime.llm.config.llm.model == 'qwen3.7-plus' + + @pytest.mark.asyncio + async def test_provider_set_error_masks_secret(self): + secret = 'sk-THIS-IS-MY-SECRET' + result = await make_router().dispatch( + make_ctx(f'/model provider set {secret}', runtime=MockRuntime())) + assert 'No fields to patch' in result.content + assert secret not in result.content + assert 'REDACTED' in result.content @pytest.mark.asyncio async def test_no_runtime(self): @@ -220,6 +811,55 @@ async def test_masks_api_keys(self): assert 'sk-test' not in result.content assert '***' in result.content + @pytest.mark.asyncio + async def test_masks_search_keys_and_mcp_headers(self): + cfg = OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'gpt-4o', + 'openai_api_key': 'sk-real-key-value', + 'base_url': + 'https://user:sk-THIS-IS-A-REAL-KEY-99@gateway.example/v1', + 'openai_base_url': + 'https://sk-THIS-IS-A-REAL-KEY-99@gateway.example/v1', + }, + 'tools': { + 'web_search': { + 'tavily_api_key': 'tvly-THIS-IS-MY-SECRET', + }, + }, + 'mcpServers': { + 'docs': { + 'url': 'https://example.invalid/mcp', + 'headers': { + 'Authorization': 'Bearer THIS-IS-MY-TOKEN', + 'Content-Type': 'application/json', + 'X-Custom': 'keep-me', + }, + 'env': { + 'PATH': '/usr/bin', + }, + }, + }, + }) + runtime = MockRuntime() + runtime.llm.config = cfg + result = await make_router().dispatch( + make_ctx('/config', runtime=runtime)) + assert 'tvly-THIS-IS-MY-SECRET' not in result.content + assert 'THIS-IS-MY-TOKEN' not in result.content + assert 'sk-real-key-value' not in result.content + assert 'tavily_api_key' in result.content + assert 'Authorization' in result.content + assert 'application/json' in result.content + assert 'keep-me' in result.content + assert '/usr/bin' in result.content + assert 'sk-THIS-IS-A-REAL-KEY-99' not in result.content + assert 'gateway.example/v1' in result.content + assert 'https://user:[REDACTED:api_key]@gateway.example/v1' in ( + result.content) + assert 'https://[REDACTED:api_key]@gateway.example/v1' in result.content + class TestQuit: @pytest.mark.asyncio @@ -258,6 +898,24 @@ async def test_excludes_plugins_key(self): result = await router.dispatch(ctx) assert 'plugins' not in result.content + @pytest.mark.asyncio + async def test_connect_failure_is_not_listed_as_ready(self): + runtime = MockRuntime() + runtime.tool_manager = type('Mgr', (), { + 'connect_failures': { + 'web_search': + 'UNAVAILABLE: Package `arxiv` is not installed. ' + 'Run: pip install arxiv, then restart ms-agent.', + }, + })() + result = await make_router().dispatch( + make_ctx('/tools', runtime=runtime)) + file_row = next(line for line in result.content.splitlines() + if 'file_system' in line) + assert 'UNAVAILABLE' not in file_row + assert '• web_search UNAVAILABLE: Package `arxiv`' in result.content + assert '• code_executor' in result.content + @pytest.mark.asyncio async def test_no_runtime(self): router = make_router() @@ -381,9 +1039,191 @@ def test_help_lists_new_commands(self): assert 'tools' in all_names assert 'compact' in all_names assert 'context' in all_names + assert 'mcp' in all_names + assert 'skills' in all_names + assert 'search' in all_names + assert 'instruction' in all_names + assert 'profile' in all_names + assert 'memory' in all_names def test_total_builtin_count(self): router = make_router() cmds = router.list_commands('cli') total = sum(len(v) for v in cmds.values()) - assert total == 12 + assert total == 18 + + +class TestModelProbe: + @pytest.fixture(autouse=True) + def _isolate_home(self, tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path / 'ms_home')) + monkeypatch.delenv('OPENAI_API_KEY', raising=False) + + @pytest.mark.asyncio + async def test_auth_rolls_back_the_new_key(self, tmp_path, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', + lambda *_a, **_k: CatalogProbe('auth', 401, [])) + _keyed('acme') + result = await make_router().dispatch( + make_ctx('/model provider key acme sk-new', runtime=MockRuntime())) + assert '没有换成它' in result.content + assert '可复制' in result.content + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['acme']['api_key'] == 'sk-test' + + @pytest.mark.asyncio + async def test_url_change_does_not_send_the_saved_key( + self, tmp_path, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + calls = [] + + def spy(url, protocol, api_key): + calls.append((url, protocol, api_key)) + return CatalogProbe('ok', 200, ['m']) + + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', spy) + _keyed('acme') + calls.clear() + result = await make_router().dispatch( + make_ctx( + '/model provider url acme https://other.example/v1', + runtime=MockRuntime())) + assert calls == [] + assert 'Saved base URL for acme.' in result.content + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['acme']['base_url'] == ( + 'https://other.example/v1') + assert data['providers']['acme']['api_key'] == 'sk-test' + + @pytest.mark.asyncio + async def test_set_url_only_does_not_probe(self, tmp_path, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + calls = [] + + def spy(url, protocol, api_key): + calls.append(api_key) + return CatalogProbe('ok', 200, ['m']) + + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', spy) + _keyed('acme') + calls.clear() + await make_router().dispatch( + make_ctx( + '/model provider set acme url=https://set.example/v1', + runtime=MockRuntime())) + assert calls == [] + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['acme']['base_url'] == 'https://set.example/v1' + assert data['providers']['acme']['api_key'] == 'sk-test' + + @pytest.mark.asyncio + async def test_new_key_is_sent_only_to_the_saved_url( + self, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + calls = [] + + def spy(url, protocol, api_key): + calls.append((url, api_key)) + return CatalogProbe('ok', 200, ['m']) + + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', spy) + _keyed('acme') + calls.clear() + await make_router().dispatch( + make_ctx( + '/model provider key acme sk-new-key', runtime=MockRuntime())) + assert calls == [('https://example.invalid/v1', 'sk-new-key')] + + @pytest.mark.asyncio + async def test_unreachable_keeps_the_new_key(self, tmp_path, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', + lambda *_a, **_k: CatalogProbe('unreachable', None, [])) + _keyed('acme') + result = await make_router().dispatch( + make_ctx( + '/model provider key acme sk-offline', runtime=MockRuntime())) + assert 'Saved API key for acme.' in result.content + assert '这个地址现在连不上,密钥还没向供应商确认。' in result.content + assert '没有保存' not in result.content + assert '没有换成它' not in result.content + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['acme']['api_key'] == 'sk-offline' + + @pytest.mark.asyncio + async def test_unreachable_keeps_a_key_and_url_written_together( + self, tmp_path, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + calls = [] + + def spy(url, protocol, api_key): + calls.append((url, api_key)) + return CatalogProbe('unreachable', None, []) + + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', spy) + result = await make_router().dispatch( + make_ctx( + '/model provider add gate ' + 'key=sk-together url=https://gate.example/v1', + runtime=MockRuntime())) + assert calls == [('https://gate.example/v1', 'sk-together')] + assert '这个地址现在连不上,密钥还没向供应商确认。' in result.content + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert data['providers']['gate']['api_key'] == 'sk-together' + assert data['providers']['gate']['base_url'] == 'https://gate.example/v1' + + @pytest.mark.asyncio + async def test_clear_key_does_not_claim_it_applied(self, tmp_path): + _keyed('acme') + result = await make_router().dispatch( + make_ctx( + '/model provider key acme clear', runtime=MockRuntime())) + assert result.content.startswith('Cleared API key for acme.') + assert '已生效' not in result.content + assert 'Live credentials' not in result.content + data = json.loads((tmp_path / 'ms_home' / 'settings.json').read_text()) + assert 'api_key' not in data['providers']['acme'] + + @pytest.mark.asyncio + async def test_missing_id_does_not_change_the_default(self, monkeypatch): + _keyed('openai') + _catalog_ok(monkeypatch, ['qwen-plus', 'qwen-max']) + runtime = MockRuntime() + result = await make_router().dispatch( + make_ctx('/model gpt-4o', runtime=runtime)) + assert result.type == CommandResultType.MESSAGE + assert '没有改默认模型' in result.content + assert 'qwen-plus' in result.content + assert runtime.llm.model == 'qwen3.7-plus' + + @pytest.mark.asyncio + async def test_no_catalog_still_saves(self, monkeypatch): + from ms_agent.llm.model_discovery import CatalogProbe + from ms_agent.config.model_settings import ModelSettingsManager + from ms_agent.project.paths import global_home + monkeypatch.setattr( + 'ms_agent.llm.model_discovery.probe_model_catalog', + lambda *_a, **_k: CatalogProbe('no_catalog', 404, [])) + _keyed('openai') + runtime = MockRuntime() + result = await make_router().dispatch( + make_ctx('/model gpt-4o', runtime=runtime)) + assert result.type == CommandResultType.MUTATE_STATE + assert '没有返回模型列表' in result.content + assert runtime.llm.model == 'gpt-4o' + assert ModelSettingsManager(global_home()).get_default_model() == ( + 'openai/gpt-4o') + + @pytest.mark.asyncio + async def test_model_list_names_non_chat_models(self): + from ms_agent.llm.model_discovery import LIST_FOOTER + result = await make_router().dispatch( + make_ctx('/model list', runtime=MockRuntime())) + assert LIST_FOOTER in result.content diff --git a/tests/command/test_resource_cmds.py b/tests/command/test_resource_cmds.py new file mode 100644 index 000000000..5c6a641c3 --- /dev/null +++ b/tests/command/test_resource_cmds.py @@ -0,0 +1,638 @@ +"""TUI /mcp and /skills write the same manager files WebUI uses.""" +from dataclasses import dataclass, field + +import pytest +from omegaconf import OmegaConf + +from ms_agent.command.builtin import register_builtin_commands +from ms_agent.command.router import CommandRouter +from ms_agent.command.types import CommandContext, CommandResultType +from ms_agent.config import MCPConfigManager +from ms_agent.config.skills_manager import SkillsConfigManager + + +def make_router(): + router = CommandRouter() + register_builtin_commands(router) + return router + + +def make_ctx(text, runtime=None, extra=None): + router = make_router() + cmd, args = CommandRouter.parse_input(text) + payload = {'router': router} + if extra: + payload.update(extra) + return CommandContext( + raw_input=text, + command_name=cmd, + args=args, + source='cli', + runtime=runtime, + extra=payload, + ) + + +class _Failure: + def __init__(self, status, last_error): + self.status = status + self.last_error = last_error + self.enabled = True + + +class _FailureRuntime: + """In-memory connect result. Listing it must not open a connection.""" + + def __init__(self, states): + self._states = states + self.connects = [] + + def get_server(self, name): + return self._states.get(name) + + async def connect_single_server(self, *args, **kwargs): + self.connects.append(args) + raise AssertionError('list/get must not connect') + + +@dataclass +class MockRuntime: + config: object = field(default_factory=lambda: OmegaConf.create({})) + tool_manager: object = None + _skill_runtime: object = None + + +@pytest.fixture(autouse=True) +def isolate_home(tmp_path, monkeypatch): + home = tmp_path / 'home' + home.mkdir() + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + return home + + +class TestMcpCommand: + @pytest.mark.asyncio + async def test_help(self): + result = await make_router().dispatch(make_ctx('/mcp')) + assert result.type == CommandResultType.MESSAGE + assert result.content.startswith('No MCP servers.') + assert '/mcp list' in result.content + + @pytest.mark.asyncio + async def test_help_flag_is_usage_only(self): + result = await make_router().dispatch(make_ctx('/mcp help')) + assert result.content.startswith('usage:') + assert 'No MCP servers' not in result.content + + @pytest.mark.asyncio + async def test_missing_args_is_focused(self): + result = await make_router().dispatch( + make_ctx('/mcp json', MockRuntime())) + assert 'Need: /mcp json ' in result.content + assert 'Got: /mcp json' in result.content + assert '/mcp add' not in result.content + + @pytest.mark.asyncio + async def test_add_list_disable_global(self, isolate_home): + runtime = MockRuntime() + router = make_router() + added = await router.dispatch( + make_ctx( + '/mcp add docs global url=https://example/mcp', + runtime=runtime)) + assert 'Added docs' in added.content + assert 'Settings' not in added.content + + listed = await router.dispatch(make_ctx('/mcp list global', runtime)) + assert 'docs' in listed.content + assert 'https://example/mcp' in listed.content + + mgr = MCPConfigManager(str(isolate_home), None) + assert 'docs' in mgr.list('global') + + disabled = await router.dispatch( + make_ctx('/mcp disable docs global', runtime)) + assert 'disable docs' in disabled.content + listed = await router.dispatch(make_ctx('/mcp list global', runtime)) + assert '[off] docs' in listed.content + + @pytest.mark.asyncio + async def test_add_project_needs_work_dir(self): + result = await make_router().dispatch( + make_ctx('/mcp add local command=npx', MockRuntime())) + assert 'work dir' in result.content + + @pytest.mark.asyncio + async def test_add_project_writes_mcp_json(self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + result = await make_router().dispatch( + make_ctx('/mcp add local command=npx', runtime)) + assert 'Added local' in result.content + assert '(project)' in result.content + assert 'Saved for this folder' in result.content + assert 'WebUI' not in result.content + mgr = MCPConfigManager(str(isolate_home), str(work)) + entry = mgr.list('project')['local'] + assert entry['command'] == 'npx' + assert entry['args'] == [] + + @pytest.mark.asyncio + async def test_add_sees_work_dir_on_agent_runtime(self, tmp_path, + isolate_home): + """Live TUI passes Runtime(llm=...), not a fake agent with .config.""" + from types import SimpleNamespace + work = tmp_path / 'repo' + work.mkdir() + runtime = SimpleNamespace( + llm=SimpleNamespace( + config=OmegaConf.create({'output_dir': str(work)}))) + result = await make_router().dispatch( + make_ctx('/mcp add live url=https://example/mcp', runtime)) + assert 'Added live (project)' in result.content + mgr = MCPConfigManager(str(isolate_home), str(work)) + assert 'live' in mgr.list('project') + + @pytest.mark.asyncio + async def test_add_splits_stdio_command_line(self, isolate_home): + result = await make_router().dispatch( + make_ctx( + '/mcp add fetch global command="npx -y @mcp/server-fetch"', + MockRuntime())) + assert 'Added fetch' in result.content + entry = MCPConfigManager(str(isolate_home), None).list('global')['fetch'] + assert entry['command'] == 'npx' + assert entry['args'] == ['-y', '@mcp/server-fetch'] + + @pytest.mark.asyncio + async def test_list_without_work_dir_does_not_crash(self, isolate_home): + await make_router().dispatch( + make_ctx('/mcp add docs global url=https://example/mcp', MockRuntime())) + result = await make_router().dispatch( + make_ctx('/mcp list', MockRuntime())) + assert 'docs' in result.content + + @pytest.mark.asyncio + async def test_list_rejects_an_extra_name(self, isolate_home): + await make_router().dispatch( + make_ctx( + '/mcp add docs global url=https://example/mcp', + MockRuntime())) + result = await make_router().dispatch( + make_ctx('/mcp list mem1', MockRuntime())) + assert 'Unknown list option.' in result.content + assert 'Need: /mcp list [global|project]' in result.content + assert 'Got: /mcp list mem1' in result.content + assert 'docs' not in result.content + assert 'https://example/mcp' not in result.content + + @pytest.mark.asyncio + async def test_list_shows_a_recorded_failure_but_not_by_name( + self, isolate_home): + runtime = MockRuntime() + runtime._mcp_runtime = _FailureRuntime({ + 'slow': _Failure('error', '6秒内没有连上'), + }) + router = make_router() + await router.dispatch( + make_ctx( + '/mcp add slow global url=http://10.255.255.1/mcp', + runtime)) + listed = await router.dispatch(make_ctx('/mcp list', runtime)) + assert '[on] slow http://10.255.255.1/mcp 6秒内没有连上' in listed.content + by_name = await router.dispatch(make_ctx('/mcp list slow', runtime)) + assert 'Unknown list option.' in by_name.content + assert '6秒内没有连上' not in by_name.content + assert '10.255.255.1' not in by_name.content + assert runtime._mcp_runtime.connects == [] + + @pytest.mark.asyncio + async def test_get_shows_the_skip_line_without_connecting( + self, isolate_home): + runtime = MockRuntime() + runtime._mcp_runtime = _FailureRuntime({ + 'slow': _Failure('error', '6秒内没有连上'), + }) + await make_router().dispatch( + make_ctx( + '/mcp add slow global url=http://10.255.255.1/mcp', + runtime)) + result = await make_router().dispatch( + make_ctx('/mcp get slow', runtime)) + assert 'url: http://10.255.255.1/mcp' in result.content + sentence = 'MCP 服务器 slow 连不上(6秒内没有连上),本轮先不用。' + assert sentence in result.content + assert result.content.index(sentence) > result.content.index('url:') + assert runtime._mcp_runtime.connects == [] + + @pytest.mark.asyncio + async def test_list_scope_word_names_the_other_copy( + self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + await make_router().dispatch( + make_ctx( + '/mcp add project global url=https://example/mcp', + runtime)) + listed = await make_router().dispatch( + make_ctx('/mcp list project', runtime)) + assert listed.content.startswith('名叫 project 的服务器不在这份清单里。') + assert 'https://example/mcp' not in listed.content + + @pytest.mark.asyncio + async def test_get_shows_one_server(self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + mgr = MCPConfigManager(str(isolate_home), str(work)) + mgr.add( + 'docs', + { + 'url': 'https://sk-THIS-IS-A-REAL-KEY-99@example/mcp', + 'headers': { + 'Authorization': 'Bearer sk-THIS-IS-A-REAL-KEY-99', + 'Content-Type': 'application/json', + }, + 'env': { + 'PATH': '/usr/bin', + 'API_KEY': 'sk-THIS-IS-A-REAL-KEY-99', + }, + }, + scope='global', + ) + mgr.add( + 'docs', + {'command': 'npx', 'args': ['-y', 'server']}, + scope='project', + ) + result = await make_router().dispatch( + make_ctx('/mcp get docs', runtime)) + assert 'docs (global):' in result.content + assert 'docs (project):' in result.content + assert 'enabled: on' in result.content + assert 'url: https://[REDACTED:api_key]@example/mcp' in result.content + assert 'Content-Type: application/json' in result.content + assert 'PATH: /usr/bin' in result.content + assert 'command: npx' in result.content + assert 'args: -y server' in result.content + assert 'sk-THIS-IS-A-REAL-KEY-99' not in result.content + assert 'Authorization: ***' in result.content + assert 'API_KEY: ***' in result.content + + @pytest.mark.asyncio + async def test_get_one_scope_and_a_missing_name(self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + await make_router().dispatch( + make_ctx( + '/mcp add project global url=https://example/mcp', + runtime)) + one = await make_router().dispatch( + make_ctx('/mcp get project global', runtime)) + assert 'project (global):' in one.content + assert 'url: https://example/mcp' in one.content + assert 'project (project):' not in one.content + missing = await make_router().dispatch( + make_ctx('/mcp get mem1', runtime)) + assert 'No MCP server named mem1.' in missing.content + assert 'Need: /mcp get [global|project]' in missing.content + + @pytest.mark.asyncio + async def test_update_changes_url(self, isolate_home): + runtime = MockRuntime() + router = make_router() + await router.dispatch( + make_ctx( + '/mcp add docs global url=https://example/mcp', + runtime=runtime)) + result = await router.dispatch( + make_ctx( + '/mcp update docs global url=https://example/v2', + runtime=runtime)) + assert 'Updated docs' in result.content + entry = MCPConfigManager(str(isolate_home), None).list('global')['docs'] + assert entry['url'] == 'https://example/v2' + + +class TestSkillsCommand: + @pytest.mark.asyncio + async def test_help(self): + result = await make_router().dispatch(make_ctx('/skills')) + assert result.content.startswith('No skills.') + assert '/skills add' in result.content + + @pytest.mark.asyncio + async def test_add_copies_into_live_tree(self, tmp_path, isolate_home): + src = tmp_path / 'my-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# Hello\n') + runtime = MockRuntime() + result = await make_router().dispatch( + make_ctx(f'/skills add {src} global', runtime)) + assert 'Imported: my-skill' in result.content + dest = SkillsConfigManager(str(isolate_home)).global_skills_tree() + assert (dest / 'my-skill' / 'SKILL.md').is_file() + + @pytest.mark.asyncio + async def test_remove_deletes_managed_copy(self, tmp_path, isolate_home): + src = tmp_path / 'my-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# Hello\n') + runtime = MockRuntime() + router = make_router() + await router.dispatch(make_ctx(f'/skills add {src} global', runtime)) + result = await router.dispatch( + make_ctx('/skills remove my-skill global', runtime)) + assert 'Removed managed skill my-skill' in result.content + dest = SkillsConfigManager(str(isolate_home)).global_skills_tree() + assert not (dest / 'my-skill').exists() + # Original import source is untouched. + assert (src / 'SKILL.md').is_file() + + @pytest.mark.asyncio + async def test_remove_non_managed_does_not_rmtree(self): + result = await make_router().dispatch( + make_ctx('/skills remove ghost global', MockRuntime())) + assert 'not a managed skill' in result.content + + @pytest.mark.asyncio + async def test_disable_writes_skills_json_even_with_runtime( + self, isolate_home): + class FakeRuntime: + def toggle(self, skill_id, enabled): + raise AssertionError('must persist via SkillsConfigManager') + + def list_all(self): + return [] + + def sync_with_config(self, _cfg): + pass + + def reload_all(self): + pass + + runtime = MockRuntime(_skill_runtime=FakeRuntime()) + await make_router().dispatch( + make_ctx('/skills disable demo global', runtime)) + data = SkillsConfigManager(str(isolate_home)).load_global() + assert 'demo' in data.get('disabled', []) + + @pytest.mark.asyncio + async def test_add_without_scope_lands_on_this_machine( + self, tmp_path, isolate_home): + src = tmp_path / 'ask-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# ask\n') + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + result = await make_router().dispatch( + make_ctx(f'/skills add {src}', runtime)) + assert 'Imported: ask-skill (global)' in result.content + assert (isolate_home / 'skills' / 'ask-skill' / 'SKILL.md').is_file() + assert not (work / '.ms_agent' / 'skills' / 'ask-skill').exists() + + @pytest.mark.asyncio + async def test_add_menu_can_place_the_skill_in_this_folder( + self, tmp_path, isolate_home): + src = tmp_path / 'ask-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# ask\n') + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + + def choose(labels, header): + assert labels == ['这台机器', '这个文件夹'] + assert header == '新技能放到哪里?' + return '这个文件夹' + + result = await make_router().dispatch( + make_ctx( + f'/skills add {src}', + runtime, + extra={'choose_scope': choose})) + assert 'Imported: ask-skill (project)' in result.content + assert (work / '.ms_agent' / 'skills' / 'ask-skill' / 'SKILL.md').is_file() + assert not (isolate_home / 'skills' / 'ask-skill').exists() + + @pytest.mark.asyncio + async def test_add_on_production_runtime_does_not_raise( + self, tmp_path, isolate_home): + from ms_agent.agent.runtime import Runtime + src = tmp_path / 'demo-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# demo\n') + result = await make_router().dispatch( + make_ctx(f'/skills add {src} global', Runtime())) + assert 'Imported: demo-skill (global)' in result.content + dest = SkillsConfigManager(str(isolate_home)).global_skills_tree() + assert (dest / 'demo-skill' / 'SKILL.md').is_file() + + @pytest.mark.asyncio + async def test_add_without_work_dir_defaults_global( + self, tmp_path, isolate_home): + src = tmp_path / 'solo-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# solo\n') + result = await make_router().dispatch( + make_ctx(f'/skills add {src}', MockRuntime())) + assert 'Imported: solo-skill (global)' in result.content + dest = SkillsConfigManager(str(isolate_home)).global_skills_tree() + assert (dest / 'solo-skill' / 'SKILL.md').is_file() + + @pytest.mark.asyncio + async def test_disable_without_scope_follows_the_added_copy( + self, tmp_path, isolate_home): + src = tmp_path / 'only-here' + src.mkdir() + (src / 'SKILL.md').write_text('# only\n') + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + router = make_router() + await router.dispatch( + make_ctx(f'/skills add {src}', runtime)) + result = await router.dispatch( + make_ctx('/skills disable only-here', runtime)) + assert 'disable only-here (global)' in result.content + glob = SkillsConfigManager(str(isolate_home)).load_global() + assert 'only-here' in glob.get('disabled', []) + proj = SkillsConfigManager(str(isolate_home)).load_project(str(work)) + assert 'only-here' not in proj.get('disabled', []) + + @pytest.mark.asyncio + async def test_list_without_runtime_shows_live_tree_ids( + self, tmp_path, isolate_home): + src = tmp_path / 'shown-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# Shown\n') + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + router = make_router() + await router.dispatch( + make_ctx(f'/skills add {src} global', runtime)) + listed = await router.dispatch(make_ctx('/skills list', runtime)) + assert 'shown-skill' in listed.content + assert listed.content.strip().startswith('Skills:') + assert 'Sources:' not in listed.content + + @pytest.mark.asyncio + async def test_disable_skill_named_project_is_not_a_scope( + self, tmp_path, isolate_home): + src = tmp_path / 'project' + src.mkdir() + (src / 'SKILL.md').write_text('# project\n') + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + router = make_router() + await router.dispatch( + make_ctx(f'/skills add {src} project', runtime)) + result = await router.dispatch( + make_ctx('/skills disable project', runtime)) + assert 'disable project (project)' in result.content + proj = SkillsConfigManager(str(isolate_home)).load_project(str(work)) + assert 'project' in proj.get('disabled', []) + + @pytest.mark.asyncio + async def test_disable_without_scope_follows_a_folder_copy( + self, tmp_path, isolate_home): + mgr = SkillsConfigManager(str(isolate_home)) + work = tmp_path / 'repo' + work.mkdir() + skill = mgr.project_skills_tree(str(work)) / 'folder-only' + skill.mkdir(parents=True) + (skill / 'SKILL.md').write_text('# folder\n') + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + result = await make_router().dispatch( + make_ctx('/skills disable folder-only', runtime)) + assert 'disable folder-only (project)' in result.content + project = mgr.load_project(str(work)) + assert 'folder-only' in (project.get('disabled') or []) + assert 'folder-only' not in (mgr.load_global().get('disabled') or []) + assert not (mgr.global_skills_tree() / 'folder-only').exists() + + @pytest.mark.asyncio + async def test_enable_without_scope_uses_existing_global_copy( + self, tmp_path, isolate_home): + mgr = SkillsConfigManager(str(isolate_home)) + skill = mgr.global_skills_tree() / 'g-only' + skill.mkdir(parents=True) + (skill / 'SKILL.md').write_text('# G\n') + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + router = make_router() + disabled = await router.dispatch( + make_ctx('/skills disable g-only global', runtime)) + assert 'disable g-only (global)' in disabled.content + enabled = await router.dispatch( + make_ctx('/skills enable g-only', runtime)) + assert 'enable g-only (global)' in enabled.content + assert 'g-only' not in (mgr.load_global().get('disabled') or []) + project = mgr.load_project(str(work)) + assert 'g-only' not in (project.get('disabled') or []) + + @pytest.mark.asyncio + async def test_remove_without_scope_deletes_existing_global_copy( + self, tmp_path, isolate_home): + mgr = SkillsConfigManager(str(isolate_home)) + skill = mgr.global_skills_tree() / 'g-only' + skill.mkdir(parents=True) + (skill / 'SKILL.md').write_text('# G\n') + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + result = await make_router().dispatch( + make_ctx('/skills remove g-only', runtime)) + assert 'Removed managed skill g-only' in result.content + assert 'not a managed skill' not in result.content + assert not skill.exists() + + @pytest.mark.asyncio + async def test_enable_without_scope_asks_when_both_copies_exist( + self, tmp_path, isolate_home): + mgr = SkillsConfigManager(str(isolate_home)) + work = tmp_path / 'repo' + work.mkdir() + for root in ( + mgr.global_skills_tree(), + mgr.project_skills_tree(str(work)), + ): + skill = root / 'g-only' + skill.mkdir(parents=True) + (skill / 'SKILL.md').write_text('# G\n') + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + result = await make_router().dispatch( + make_ctx('/skills enable g-only', runtime)) + assert 'both global and project' in result.content + assert 'Pass global or project.' in result.content + assert 'g-only' not in (mgr.load_global().get('disabled') or []) + project = mgr.load_project(str(work)) + assert 'g-only' not in (project.get('disabled') or []) + + @pytest.mark.asyncio + async def test_both_copies_menu_changes_only_the_chosen_one( + self, tmp_path, isolate_home): + mgr = SkillsConfigManager(str(isolate_home)) + work = tmp_path / 'repo' + work.mkdir() + for root in ( + mgr.global_skills_tree(), + mgr.project_skills_tree(str(work)), + ): + skill = root / 'g-only' + skill.mkdir(parents=True) + (skill / 'SKILL.md').write_text('# G\n') + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + + def choose(labels, header): + assert labels == ['这台机器', '这个文件夹'] + assert '两处都有' in header + return '这个文件夹' + + result = await make_router().dispatch( + make_ctx( + '/skills disable g-only', + runtime, + extra={'choose_scope': choose})) + assert 'disable g-only (project)' in result.content + assert 'g-only' not in (mgr.load_global().get('disabled') or []) + project = mgr.load_project(str(work)) + assert 'g-only' in (project.get('disabled') or []) + + @pytest.mark.asyncio + async def test_mcp_add_server_named_project(self, tmp_path, isolate_home): + work = tmp_path / 'repo' + work.mkdir() + runtime = MockRuntime( + config=OmegaConf.create({'output_dir': str(work)})) + result = await make_router().dispatch( + make_ctx( + '/mcp add project url=https://example.invalid/mcp', runtime)) + assert 'Added project (project)' in result.content + assert 'Need:' not in result.content + + @pytest.mark.asyncio + async def test_alias_skill_mgr(self): + result = await make_router().dispatch(make_ctx('/skill-mgr')) + assert '/skills add' in result.content diff --git a/tests/command/test_scope.py b/tests/command/test_scope.py new file mode 100644 index 000000000..2e045df7a --- /dev/null +++ b/tests/command/test_scope.py @@ -0,0 +1,36 @@ +"""Scope helper: omit-token parsing and work-dir lookup.""" +from types import SimpleNamespace + +from omegaconf import OmegaConf + +from ms_agent.command.scope import parse_optional_scope, work_dir_of +from ms_agent.command.types import CommandContext + + +def test_parse_optional_scope_needs_a_name_before_the_token(): + assert parse_optional_scope(['project']) == (None, ['project']) + assert parse_optional_scope(['demo', 'project']) == ('project', ['demo']) + assert parse_optional_scope(['/tmp/foo']) == (None, ['/tmp/foo']) + assert parse_optional_scope(['/tmp/foo', 'global']) == ( + 'global', ['/tmp/foo']) + + +def test_work_dir_prefers_llm_config(): + ctx = CommandContext( + raw_input='/mcp', + command_name='mcp', + runtime=SimpleNamespace( + llm=SimpleNamespace( + config=OmegaConf.create({'output_dir': '/work'}))), + ) + assert work_dir_of(ctx) == '/work' + + +def test_work_dir_accepts_fake_agent_config(): + ctx = CommandContext( + raw_input='/mcp', + command_name='mcp', + runtime=SimpleNamespace( + config=OmegaConf.create({'output_dir': '/agent'})), + ) + assert work_dir_of(ctx) == '/agent' diff --git a/tests/command/test_search_cmds.py b/tests/command/test_search_cmds.py new file mode 100644 index 000000000..8ec512fdd --- /dev/null +++ b/tests/command/test_search_cmds.py @@ -0,0 +1,147 @@ +"""TUI /search writes the same tools.web_search block WebUI uses.""" +import json + +import pytest + +from ms_agent.command.builtin import register_builtin_commands +from ms_agent.command.router import CommandRouter +from ms_agent.command.types import CommandContext + + +def make_router(): + router = CommandRouter() + register_builtin_commands(router) + return router + + +def make_ctx(text, runtime=None): + router = make_router() + cmd, args = CommandRouter.parse_input(text) + return CommandContext( + raw_input=text, + command_name=cmd, + args=args, + source='cli', + runtime=runtime, + extra={'router': router}, + ) + + +@pytest.fixture(autouse=True) +def isolate_home(tmp_path, monkeypatch): + home = tmp_path / 'home' + home.mkdir() + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + return home + + +class TestSearchCommand: + @pytest.mark.asyncio + async def test_default_status(self, isolate_home): + result = await make_router().dispatch(make_ctx('/search')) + assert 'Engine: tavily' in result.content + assert '/search engine' in result.content + assert str(isolate_home) in result.content + assert 'WebUI' not in result.content + assert 'MS_AGENT_HOME' not in result.content + + @pytest.mark.asyncio + async def test_list_marks_current(self): + result = await make_router().dispatch(make_ctx('/search list')) + assert '* tavily' in result.content + assert 'arxiv' in result.content + + @pytest.mark.asyncio + async def test_engine_writes_settings_json(self, isolate_home): + result = await make_router().dispatch( + make_ctx('/search engine arxiv')) + assert 'Engine → arxiv' in result.content + assert '/new' not in result.content + assert 'restart' not in result.content + data = json.loads((isolate_home / 'settings.json').read_text()) + assert data['tools']['web_search']['engine'] == 'arxiv' + + @pytest.mark.asyncio + async def test_key_is_per_engine(self, isolate_home): + await make_router().dispatch(make_ctx('/search engine exa')) + result = await make_router().dispatch( + make_ctx('/search key sk-exa-test')) + assert 'saved' in result.content + data = json.loads((isolate_home / 'settings.json').read_text()) + assert data['tools']['web_search']['exa_api_key'] == 'sk-exa-test' + await make_router().dispatch(make_ctx('/search engine tavily')) + data = json.loads((isolate_home / 'settings.json').read_text()) + assert data['tools']['web_search']['engine'] == 'tavily' + assert data['tools']['web_search']['exa_api_key'] == 'sk-exa-test' + + @pytest.mark.asyncio + async def test_key_clear(self, isolate_home): + await make_router().dispatch(make_ctx('/search engine exa')) + await make_router().dispatch(make_ctx('/search key abc')) + result = await make_router().dispatch(make_ctx('/search key clear')) + assert 'cleared' in result.content + data = json.loads((isolate_home / 'settings.json').read_text()) + assert 'exa_api_key' not in data['tools']['web_search'] + + @pytest.mark.asyncio + async def test_arxiv_rejects_key(self): + await make_router().dispatch(make_ctx('/search engine arxiv')) + result = await make_router().dispatch(make_ctx('/search key nope')) + assert 'does not use an API key' in result.content + + @pytest.mark.asyncio + async def test_disable(self, isolate_home): + result = await make_router().dispatch(make_ctx('/search disable')) + assert 'disabled' in result.content + data = json.loads((isolate_home / 'settings.json').read_text()) + assert data['tools']['web_search']['enabled'] is False + + @pytest.mark.asyncio + async def test_missing_package_stays_keyless_and_says_how_to_install( + self, monkeypatch): + def missing(engine): + return {'arxiv': 'arxiv', 'exa': 'exa-py'}.get(engine) + + monkeypatch.setattr( + 'ms_agent.tools.search.availability._missing_pip', missing) + await make_router().dispatch(make_ctx('/search engine arxiv')) + status = await make_router().dispatch(make_ctx('/search')) + sentence = ( + 'UNAVAILABLE: Package `arxiv` is not installed. ' + 'Run: pip install arxiv, then restart ms-agent.') + assert 'API key: not required' in status.content + assert sentence in status.content + head = status.content.split('usage:')[0].splitlines() + assert head[3] == sentence + + listed = await make_router().dispatch(make_ctx('/search list')) + arxiv_row = next(line for line in listed.content.splitlines() + if ' arXiv ' in line) + assert 'key=n/a' in arxiv_row + assert 'keyless-ok' in arxiv_row + assert sentence in arxiv_row + tavily_row = next(line for line in listed.content.splitlines() + if ' Tavily ' in line) + assert 'keyless-ok' in tavily_row + assert 'UNAVAILABLE' not in tavily_row + + await make_router().dispatch(make_ctx('/search engine exa')) + bare = await make_router().dispatch(make_ctx('/search')) + assert 'UNAVAILABLE' not in bare.content + await make_router().dispatch(make_ctx('/search key sk-test')) + keyed = await make_router().dispatch(make_ctx('/search')) + assert ('UNAVAILABLE: Package `exa-py` is not installed. ' + 'Run: pip install exa-py, then restart ms-agent.' + ) in keyed.content + + @pytest.mark.asyncio + async def test_engine_switch_does_not_need_runtime_config( + self, isolate_home): + from ms_agent.agent.runtime import Runtime + runtime = Runtime() + result = await make_router().dispatch( + make_ctx('/search engine serpapi', runtime)) + assert 'Engine → serpapi' in result.content + data = json.loads((isolate_home / 'settings.json').read_text()) + assert data['tools']['web_search']['engine'] == 'serpapi' + assert not hasattr(runtime, 'config') diff --git a/tests/command/test_usage.py b/tests/command/test_usage.py new file mode 100644 index 000000000..836f299de --- /dev/null +++ b/tests/command/test_usage.py @@ -0,0 +1,30 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +from ms_agent.command.types import CommandContext +from ms_agent.command.usage import arg_error + + +def test_arg_error_is_one_syntax_line(): + result = arg_error( + '/model catalog remove ', + reason='Unknown catalog action \'foo\'', + note='drop = remove', + got='/model catalog foo bar', + ) + text = result.content + assert text.startswith("Unknown catalog action 'foo'.") + assert 'Need: /model catalog remove ' in text + assert 'Got: /model catalog foo bar' in text + assert 'drop = remove' in text + assert 'provider add' not in text + + +def test_arg_error_fills_got_from_ctx(): + ctx = CommandContext( + raw_input='/model provider key', + command_name='model', + args='provider key', + ) + result = arg_error( + '/model provider key |clear', ctx=ctx) + assert 'Need: /model provider key |clear' in result.content + assert 'Got: /model provider key' in result.content diff --git a/tests/config/test_model_settings.py b/tests/config/test_model_settings.py index 2baabd3d2..cb2138d5a 100644 --- a/tests/config/test_model_settings.py +++ b/tests/config/test_model_settings.py @@ -25,8 +25,14 @@ def test_models_and_default(tmp_path): assert 'a-2' in m.list_custom_providers()['acme']['models'] m.set_default_model('a-2', provider='acme') assert m.get_default_model() == 'acme/a-2' + data = json.loads((tmp_path / 'settings.json').read_text()) + assert data['llm']['provider'] == 'acme' + assert data['llm']['model'] == 'a-2' + m.set_default_model('acme glued-id', provider='acme') + assert m.get_default_model() == 'acme/glued-id' m.remove_model('acme', 'a-2') assert 'a-2' not in m.list_custom_providers()['acme']['models'] + assert m.remove_model('acme', 'missing') is False def test_add_model_materializes_a_provider_without_inventing_metadata(tmp_path): @@ -52,13 +58,50 @@ def test_preserves_other_sections(tmp_path): assert 'acme' in data['providers'] +def test_patch_provider_does_not_reset_protocol(tmp_path): + m = ModelSettingsManager(global_dir=str(tmp_path)) + m.add_provider( + 'acme', protocol='anthropic', api_key='old', + base_url='https://old/v1') + m.patch_provider('acme', api_key='new') + entry = m.list_custom_providers()['acme'] + assert entry['protocol'] == 'anthropic' + assert entry['api_key'] == 'new' + assert entry['base_url'] == 'https://old/v1' + m.patch_provider('acme', clear_api_key=True) + assert 'api_key' not in m.list_custom_providers()['acme'] + + def test_resolver_consumes_default_model(): from ms_agent.config.resolver import ConfigResolver cfg = ConfigResolver._settings_to_agent_config( {'default_model': 'deepseek/deepseek-chat'}) assert cfg.llm.service == 'deepseek' assert cfg.llm.model == 'deepseek-chat' + glued = ConfigResolver._settings_to_agent_config( + {'default_model': 'minimax/minimax MiniMax-M2.1'}) + assert glued.llm.service == 'minimax' + assert glued.llm.model == 'MiniMax-M2.1' # explicit llm.model wins over default_model cfg2 = ConfigResolver._settings_to_agent_config( {'llm': {'model': 'pinned'}, 'default_model': 'deepseek/x'}) assert cfg2.llm.model == 'pinned' + + +def test_resolver_copies_provider_catalog_credentials(): + from ms_agent.config.resolver import ConfigResolver + cfg = ConfigResolver._settings_to_agent_config({ + 'default_model': 'openai/qwen3.7-plus', + 'providers': { + 'openai': { + 'api_key': 'sk-cat', + 'base_url': 'https://example.invalid/v1', + 'protocol': 'openai', + }, + }, + }) + assert cfg.llm.service == 'openai' + assert cfg.llm.model == 'qwen3.7-plus' + assert cfg.llm.openai_api_key == 'sk-cat' + assert cfg.llm.openai_base_url == 'https://example.invalid/v1' + assert cfg.llm.protocol == 'openai' diff --git a/tests/config/test_search_settings.py b/tests/config/test_search_settings.py new file mode 100644 index 000000000..1430d6054 --- /dev/null +++ b/tests/config/test_search_settings.py @@ -0,0 +1,73 @@ +import json + +from ms_agent.config.search_settings import SearchSettingsManager + + +def test_default_engine_when_empty(tmp_path): + m = SearchSettingsManager(tmp_path) + cur = m.get() + assert cur.engine == 'tavily' + assert cur.enabled is True + assert cur.has_key is False + assert cur.supports_keyless is True + + +def test_set_engine_preserves_other_keys(tmp_path): + m = SearchSettingsManager(tmp_path) + m.set_engine('exa') + m.set_api_key('exa-secret') + m.set_engine('tavily') + data = json.loads((tmp_path / 'settings.json').read_text()) + block = data['tools']['web_search'] + assert block['engine'] == 'tavily' + assert block['exa_api_key'] == 'exa-secret' + assert block.get('mcp') is False + + +def test_clear_exa_key_drops_legacy_aliases(tmp_path): + p = tmp_path / 'settings.json' + p.write_text(json.dumps({ + 'tools': { + 'web_search': { + 'engine': 'exa', + 'exa_api_key': 'new', + 'api_key': 'legacy', + } + } + })) + m = SearchSettingsManager(tmp_path) + assert m.get().has_key is True + m.set_api_key(None) + block = json.loads(p.read_text())['tools']['web_search'] + assert 'exa_api_key' not in block + assert 'api_key' not in block + assert m.get().has_key is False + + +def test_arxiv_rejects_key(tmp_path): + m = SearchSettingsManager(tmp_path) + m.set_engine('arxiv') + try: + m.set_api_key('x') + raise AssertionError('expected ValueError') + except ValueError as exc: + assert 'arxiv' in str(exc) + + +def test_unknown_engine_rejected(tmp_path): + m = SearchSettingsManager(tmp_path) + try: + m.set_engine('bing') + raise AssertionError('expected ValueError') + except ValueError as exc: + assert 'Unknown' in str(exc) + + +def test_preserves_unrelated_settings(tmp_path): + p = tmp_path / 'settings.json' + p.write_text(json.dumps({'theme': 'dark', 'llm': {'model': 'x'}})) + m = SearchSettingsManager(tmp_path) + m.set_engine('arxiv') + data = json.loads(p.read_text()) + assert data['theme'] == 'dark' + assert data['llm']['model'] == 'x' diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 000000000..b06cd5691 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,139 @@ +"""Shared fixtures for TUI / WebUI ledger e2e tests. + +Both sides must share one ``MS_AGENT_HOME``. These tests never touch +``~/.ms_agent``. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from omegaconf import OmegaConf + +from ms_agent.prompting import workspace_files as wf + +from tests.e2e.helpers import DummyLLM + +SDK_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_WEBUI_BACKEND = Path( + os.environ.get( + 'MS_AGENT_WEBUI_BACKEND', + '/Users/luyan/workspace/ms-agent-webui-feat-tui-align/backend', + )) + +try: + from dotenv import load_dotenv + load_dotenv(SDK_ROOT / '.env') +except Exception: + pass + + +def pytest_configure(config): + config.addinivalue_line( + 'markers', + 'live: hits a real model / search provider (needs API key in .env)', + ) + config.addinivalue_line( + 'markers', + 'usability: user/developer-facing contract that may xfail on product bugs', + ) + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + home = tmp_path / 'ms_agent_home' + home.mkdir() + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + monkeypatch.delenv('MS_AGENT_LLM_MODEL', raising=False) + monkeypatch.delenv('MS_AGENT_LLM_PROVIDER', raising=False) + wf.reset_cache() + yield home + wf.reset_cache() + + +@pytest.fixture +def work_dir(tmp_path): + work = tmp_path / 'align-work' + work.mkdir() + return work + + +@pytest.fixture +def stub_llm_rebuild(monkeypatch): + """Let ``/model `` persist without talking to a real gateway.""" + + def _from_config(config): + return DummyLLM( + config, + str(OmegaConf.select(config, 'llm.model', default='') or ''), + ) + + monkeypatch.setattr('ms_agent.llm.LLM.from_config', _from_config) + return _from_config + + +def webui_backend() -> Path: + if not DEFAULT_WEBUI_BACKEND.is_dir(): + pytest.skip(f'WebUI backend not found: {DEFAULT_WEBUI_BACKEND}') + return DEFAULT_WEBUI_BACKEND + + +def _ensure_webui_on_path() -> Path: + backend = webui_backend() + inserted = str(backend) + if inserted not in sys.path: + sys.path.insert(0, inserted) + return backend + + +@pytest.fixture +def webui(isolated_home): + """Import GitLab WebUI adapters against the isolated home. + + Adapters read ``MS_AGENT_HOME`` on each call, so they see the same + ledger TUI slash commands write. + """ + _ensure_webui_on_path() + from app.backends.ms_agent import ( + agent_settings, + instructions, + mcps, + profile, + projects, + providers, + search, + sessions, + skills, + ) + return SimpleNamespace( + agent_settings=agent_settings, + instructions=instructions, + mcps=mcps, + profile=profile, + projects=projects, + providers=providers, + search=search, + sessions=sessions, + skills=skills, + ) + + +@pytest.fixture +def webui_client(isolated_home): + """HTTP TestClient for the GitLab WebUI backend (same isolated home).""" + _ensure_webui_on_path() + pytest.importorskip('fastapi') + from fastapi.testclient import TestClient + + from app.main import create_app + + with TestClient(create_app()) as client: + yield client + try: + from app.backends.ms_agent.skill_index import skill_index + skill_index.stop() + except Exception: + pass diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 000000000..adb0473ea --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,89 @@ +"""Helpers for TUI / WebUI ledger e2e tests (imported by tests, not conftest).""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from pathlib import Path +from types import SimpleNamespace + +from omegaconf import OmegaConf + +from ms_agent.command.builtin import register_builtin_commands +from ms_agent.command.router import CommandRouter +from ms_agent.command.types import CommandContext + + +def make_router() -> CommandRouter: + router = CommandRouter() + register_builtin_commands(router) + return router + + +def make_ctx(text: str, runtime=None) -> CommandContext: + router = make_router() + cmd, args = CommandRouter.parse_input(text) + return CommandContext( + raw_input=text, + command_name=cmd, + args=args, + source='tui', + runtime=runtime, + extra={'router': router}, + ) + + +async def slash(text: str, runtime=None): + ctx = make_ctx(text, runtime) + return await ctx.extra['router'].dispatch(ctx) + + +def run_slash(text: str, runtime=None): + """Sync wrapper so e2e tests do not require pytest-asyncio.""" + return asyncio.run(slash(text, runtime)) + + +def runtime_for(work: Path): + return SimpleNamespace( + config=OmegaConf.create({'output_dir': str(work)}), + tool_manager=None, + _skill_runtime=None, + memory_tools=[], + llm=None, + ) + + +@dataclass +class DummyLLM: + config: object + model: str = '' + + def __post_init__(self): + if not self.model: + self.model = str( + OmegaConf.select(self.config, 'llm.model', default='') or '') + + +@dataclass +class ModelRuntime: + work: Path + model: str = 'qwen3.7-plus' + service: str = 'openai' + api_key: str = 'sk-test-not-real' + memory_tools: list = field(default_factory=list) + tool_manager: object = None + _skill_runtime: object = None + + def __post_init__(self): + self.config = OmegaConf.create({ + 'output_dir': str(self.work), + 'llm': { + 'service': self.service, + 'model': self.model, + 'openai_api_key': self.api_key, + 'use_provider_router': True, + }, + }) + self.llm = DummyLLM(self.config, self.model) + + async def load_memory(self): + self.memory_tools.append('loaded') diff --git a/tests/e2e/test_tui_webui_align.py b/tests/e2e/test_tui_webui_align.py new file mode 100644 index 000000000..112cb55a1 --- /dev/null +++ b/tests/e2e/test_tui_webui_align.py @@ -0,0 +1,571 @@ +"""TUI / WebUI settings-ledger e2e (docs/tui-webui-align-e2e.md). + +Alignment means both surfaces read and write the same files under one +``MS_AGENT_HOME``, not identical UIs. Disk is the source of truth; WebUI +adapters / HTTP are the other reader. + +These tests drive the same slash router the TUI uses, then assert the ledger +and (when present) the GitLab WebUI backend. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from omegaconf import OmegaConf + +from ms_agent.config.config import Config +from ms_agent.project import SessionManager +from ms_agent.tui.app import TuiApp + +from tests.e2e.helpers import ModelRuntime, runtime_for, run_slash + + +def _settings(home: Path) -> dict: + path = home / 'settings.json' + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding='utf-8')) + + +# --------------------------------------------------------------------------- +# A / period 0 — same folder, same project, same sessions +# --------------------------------------------------------------------------- + + +class TestProjectAndSession: + def test_a1_same_folder_is_one_project(self, isolated_home, work_dir, + webui): + from ms_agent.project.manager import ProjectManager + + created = webui.projects.create_project( + __import__( + 'app.schemas.project', fromlist=['ProjectCreate'] + ).ProjectCreate(name='from-webui', local_path=str(work_dir))) + tui = TuiApp._open_project(str(work_dir)) + assert tui.id == created.id + listed = ProjectManager(base_dir=str(isolated_home)).get(tui.id) + assert listed is not None + assert Path(listed.path).resolve() == work_dir.resolve() + + def test_a2_webui_session_is_resumable_in_tui(self, isolated_home, + work_dir, webui): + from app.schemas.project import ProjectCreate + from app.schemas.session import SessionCreate + + project = webui.projects.create_project( + ProjectCreate(name='shared', local_path=str(work_dir))) + session = webui.sessions.create_session( + SessionCreate(title='web chat', project_id=project.id)) + sdk = SessionManager(TuiApp._open_project(str(work_dir))) + sdk.get_session_log(sdk.get(session.id)).append( + {'role': 'user', 'content': 'hello from web'}) + + tui_proj = TuiApp._open_project(str(work_dir)) + tui_sm = SessionManager(tui_proj) + assert session.id in {s.id for s in tui_sm.list()} + msgs = tui_sm.get_session_log(tui_sm.get(session.id)).get_all_messages() + assert any(m.get('content') == 'hello from web' for m in msgs) + + app = TuiApp.__new__(TuiApp) + app._sm = tui_sm + app.session = None + found = app._resume_target(session.id) + assert found is not None and found.id == session.id + by_index = app._resume_target('0') + assert by_index is not None and by_index.id == session.id + + def test_a3_tui_session_shows_up_in_webui(self, isolated_home, work_dir, + webui): + from app.schemas.project import ProjectCreate + + tui_proj = TuiApp._open_project(str(work_dir)) + sess = SessionManager(tui_proj).create(name='from tui') + SessionManager(tui_proj).get_session_log(sess).append( + {'role': 'user', 'content': 'hello from tui first'}) + + projects = webui.projects.list_projects() + assert any(p.id == tui_proj.id for p in projects) + # Opening the same folder through WebUI must not mint a second id. + again = webui.projects.create_project( + ProjectCreate(name='reopen', local_path=str(work_dir))) + assert again.id == tui_proj.id + ids = [s.id for s in webui.sessions.list_sessions(tui_proj.id)] + assert sess.id in ids + + def test_a4_empty_home_does_not_mcp_todo_list(self, isolated_home, + work_dir): + cfg = TuiApp._load_runtime_config( + 'unused.yaml', str(work_dir), explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work_dir)) + TuiApp._bind_todo_list_session(cfg, str(work_dir / 'sess')) + assert cfg.tools.todo_list.mcp is False + servers = Config.convert_mcp_servers_to_json(cfg)['mcpServers'] + assert 'todo_list' not in servers + + def test_a5_default_tui_uses_webui_default_model(self, isolated_home, + work_dir, webui): + from app.backends.ms_agent import model_link + + model_link.set_active_model('openai', 'qwen3.7-plus') + data = _settings(isolated_home) + assert data.get('default_model') == 'openai/qwen3.7-plus' + cfg = TuiApp._load_runtime_config( + 'unused.yaml', str(work_dir), explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work_dir)) + assert cfg.llm.service == 'openai' + assert cfg.llm.model == 'qwen3.7-plus' + + def test_a6_explicit_config_not_overridden_by_settings( + self, isolated_home, work_dir, tmp_path): + (isolated_home / 'settings.json').write_text( + json.dumps({ + 'default_model': 'openai/qwen3.7-plus', + 'llm': { + 'provider': 'openai', + 'model': 'qwen3.7-plus', + }, + }), + encoding='utf-8') + yaml_path = tmp_path / 'custom.yaml' + yaml_path.write_text( + 'llm:\n service: modelscope\n model: from-yaml\n' + 'tools:\n file_system:\n mcp: false\n', + encoding='utf-8') + cfg = TuiApp._load_runtime_config( + str(yaml_path), str(work_dir), explicit_config=True) + assert cfg.llm.model == 'from-yaml' + assert cfg.llm.service == 'modelscope' + + @pytest.mark.live + def test_a5_live_first_turn_uses_seeded_model(self, isolated_home, + work_dir): + key = os.environ.get('DASHSCOPE_API_KEY', '').strip() + if not key: + pytest.skip('DASHSCOPE_API_KEY missing') + (isolated_home / 'settings.json').write_text( + json.dumps({ + 'default_model': 'dashscope/qwen3.7-plus', + 'llm': { + 'provider': 'dashscope', + 'model': 'qwen3.7-plus', + }, + 'providers': { + 'dashscope': { + 'api_key': key, + 'protocol': 'openai', + }, + }, + }), + encoding='utf-8') + cfg = TuiApp._load_runtime_config( + 'unused.yaml', str(work_dir), explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work_dir)) + assert cfg.llm.model == 'qwen3.7-plus' + assert cfg.llm.service == 'dashscope' + OmegaConf.update(cfg, 'generation_config.stream', False, merge=True) + OmegaConf.update(cfg, 'generation_config.max_tokens', 32, merge=True) + from ms_agent.llm import LLM + from ms_agent.llm.utils import Message + + llm = LLM.from_config(cfg) + assert getattr(llm, 'model', '') in ('qwen3.7-plus', cfg.llm.model) + reply = llm.generate( + messages=[ + Message(role='user', content='Reply with exactly: pong'), + ], + tools=None) + if hasattr(reply, '__iter__') and not hasattr(reply, 'content'): + chunk = None + for chunk in reply: + pass + reply = chunk + assert reply is not None and reply.content + assert 'sk-' not in (reply.content or '') + + +# --------------------------------------------------------------------------- +# B / period 3 — model providers +# --------------------------------------------------------------------------- + + +class TestModelLedger: + def test_b1_list_hides_plaintext_key(self, isolated_home, work_dir, + stub_llm_rebuild): + rt = ModelRuntime(work_dir) + run_slash( + '/model provider add acme key=sk-secret-plaintext ' + 'url=https://example.invalid/v1 protocol=openai', + rt) + listed = run_slash('/model list', rt) + assert 'acme' in listed.content + assert 'sk-secret-plaintext' not in listed.content + assert 'key=sk-s****text' in listed.content + settings = _settings(isolated_home) + assert settings['providers']['acme']['api_key'] == 'sk-secret-plaintext' + + def test_b2_switch_updates_default_model(self, isolated_home, + work_dir, + stub_llm_rebuild): + rt = ModelRuntime(work_dir) + run_slash( + '/model provider add acme key=sk-acme ' + 'url=https://example.invalid/v1 protocol=openai', + rt) + run_slash('/model catalog add acme a-1', rt) + result = run_slash('/model acme/a-1', rt) + assert 'a-1' in result.content + data = _settings(isolated_home) + assert data.get('default_model') == 'acme/a-1' + shown = run_slash('/model', rt) + assert 'a-1' in shown.content + + def test_b3_b7_provider_and_catalog_visible_to_webui( + self, isolated_home, work_dir, stub_llm_rebuild, webui): + rt = ModelRuntime(work_dir) + run_slash( + '/model provider add acme key=sk-test-acme ' + 'url=https://example.invalid/v1 protocol=openai', + rt) + run_slash('/model catalog add acme a-1', rt) + rows = {p.id: p for p in webui.providers.list_providers()} + assert 'acme' in rows + assert rows['acme'].base_url == 'https://example.invalid/v1' + assert 'sk-test-acme' not in (rows['acme'].api_key_masked or '') + data = _settings(isolated_home) + assert data['providers']['acme']['models'] == ['a-1'] + + def test_b4_key_update_and_b6_patch_preserves_other_fields( + self, isolated_home, work_dir, stub_llm_rebuild): + rt = ModelRuntime(work_dir) + run_slash( + '/model provider add acme key=sk-old ' + 'url=https://example.invalid/v1 protocol=openai name=Acme', + rt) + run_slash('/model provider key acme sk-new', rt) + run_slash('/model provider set acme protocol=anthropic', rt) + entry = _settings(isolated_home)['providers']['acme'] + assert entry['api_key'] == 'sk-new' + assert entry['base_url'] == 'https://example.invalid/v1' + assert entry['protocol'] == 'anthropic' + assert entry.get('name') == 'Acme' + + def test_b8_cannot_delete_builtin_openai(self, isolated_home, + work_dir, + stub_llm_rebuild): + rt = ModelRuntime(work_dir) + result = run_slash('/model provider remove openai', rt) + assert 'Cannot remove' in result.content + listed = run_slash('/model list', rt) + assert 'openai' in listed.content + run_slash('/model provider key openai sk-override', rt) + cleared = run_slash('/model provider remove openai', rt) + assert 'Builtin catalog remains' in cleared.content + listed = run_slash('/model list', rt) + assert 'openai' in listed.content + assert 'sk-override' not in listed.content + + +# --------------------------------------------------------------------------- +# C / period 1 — search +# --------------------------------------------------------------------------- + + +class TestSearchLedger: + def test_c1_default_engine_tavily(self, isolated_home): + status = run_slash('/search') + assert 'Engine: tavily' in status.content + listed = run_slash('/search list') + assert '* tavily' in listed.content + + def test_c2_c4_engine_switch_keeps_other_keys(self, isolated_home, + webui): + run_slash('/search engine exa') + run_slash('/search key sk-test-exa') + run_slash('/search engine tavily') + block = _settings(isolated_home)['tools']['web_search'] + assert block['engine'] == 'tavily' + assert block['exa_api_key'] == 'sk-test-exa' + ui = webui.search.get_settings() + assert ui.provider == 'tavily' + + from app.schemas.search import SearchSettingsUpdate + webui.search.update_settings( + SearchSettingsUpdate(enabled=True, provider='exa')) + status = run_slash('/search') + assert 'Engine: exa' in status.content + listed = run_slash('/search list') + assert '* exa' in listed.content + assert 'key=set' in listed.content + + def test_c3_arxiv_rejects_key(self, isolated_home, webui): + run_slash('/search engine arxiv') + result = run_slash('/search key nope') + assert 'does not use an API key' in result.content + block = _settings(isolated_home)['tools']['web_search'] + assert 'arxiv_api_key' not in block + providers = {p.id: p for p in webui.search.list_providers()} + assert providers['arxiv'].requires_key is False + ui = webui.search.get_settings() + assert ui.provider == 'arxiv' + + def test_c5_enable_disable(self, isolated_home, webui): + run_slash('/search disable') + assert _settings(isolated_home)['tools']['web_search']['enabled'] is False + assert webui.search.get_settings().enabled is False + run_slash('/search enable') + assert webui.search.get_settings().enabled is True + + def test_c6_webui_to_tui_engine(self, isolated_home, webui): + from app.schemas.search import SearchSettingsUpdate + webui.search.update_settings( + SearchSettingsUpdate(enabled=True, provider='serpapi')) + status = run_slash('/search') + assert 'Engine: serpapi' in status.content + listed = run_slash('/search list') + assert '* serpapi' in listed.content + + +# --------------------------------------------------------------------------- +# D / period 2 — instructions + profile +# --------------------------------------------------------------------------- + + +class TestInstructionAndProfile: + def test_d1_d2_global_instruction_shared(self, isolated_home, webui): + result = run_slash('/instruction global Always answer in French.') + assert 'saved' in result.content.lower() + text = (isolated_home / 'AGENTS.md').read_text(encoding='utf-8') + assert 'Always answer in French.' in text + assert text.lstrip().startswith('---') + from app.schemas.instruction import InstructionUpsert + ui = webui.instructions.get_instruction('global') + assert 'Always answer in French.' in ui.content + webui.instructions.upsert_instruction( + 'global', InstructionUpsert(content='Be terse.')) + shown = run_slash('/instruction global') + assert 'Be terse.' in shown.content + + def test_d3_project_instruction_never_writes_repo_root( + self, isolated_home, work_dir, webui): + root = work_dir / 'AGENTS.md' + root.write_text('# team\nkeep me at root\n', encoding='utf-8') + rt = runtime_for(work_dir) + TuiApp._open_project(str(work_dir)) + run_slash('/instruction project This project uses FastAPI.', rt) + private = work_dir / '.ms_agent' / 'AGENTS.md' + assert private.read_text(encoding='utf-8').strip() == ( + 'This project uses FastAPI.') + assert root.read_text(encoding='utf-8') == '# team\nkeep me at root\n' + shown = run_slash('/instruction', rt) + assert 'FastAPI' in shown.content + assert 'never writes' in shown.content.lower() or 'Repo-root' in shown.content + + def test_d4_d6_profile_fields_independent(self, isolated_home, webui): + run_slash('/profile callme Alice') + run_slash('/profile about I work on agents.') + shown = run_slash('/profile') + assert 'Alice' in shown.content + assert 'I work on agents.' in shown.content + ui = webui.profile.get_profile() + assert ui.agent_calls_user == 'Alice' + assert 'I work on agents.' in (ui.description or '') + run_slash('/profile callme clear') + call_text = (isolated_home / 'PROFILE.md').read_text(encoding='utf-8') + assert 'Alice' not in call_text + assert 'I work on agents.' in call_text + ui = webui.profile.get_profile() + assert not ui.agent_calls_user + assert 'I work on agents.' in (ui.description or '') + + +# --------------------------------------------------------------------------- +# E / period 4 — MCP +# --------------------------------------------------------------------------- + + +class TestMcpLedger: + def test_e1_e6_http_crud_matches_webui(self, isolated_home, webui): + rt = runtime_for(Path('.')) + added = run_slash( + '/mcp add docs global url=https://example.invalid/mcp', rt) + assert 'Added docs' in added.content + listed = run_slash('/mcp list global', rt) + assert 'docs' in listed.content + assert 'https://example.invalid/mcp' in listed.content + ui = {m.name: m for m in webui.mcps.list_mcps('global')} + assert ui['docs'].endpoint == 'https://example.invalid/mcp' + + updated = run_slash( + '/mcp update docs global url=https://example.invalid/v2', rt) + assert 'Updated docs' in updated.content + mcp_json = json.loads( + (isolated_home / 'mcp.json').read_text(encoding='utf-8')) + servers = mcp_json.get('mcpServers') or mcp_json + assert servers['docs']['url'] == 'https://example.invalid/v2' + ui = {m.name: m for m in webui.mcps.list_mcps('global')} + assert ui['docs'].endpoint == 'https://example.invalid/v2' + assert len(ui) == 1 + + run_slash('/mcp disable docs global', rt) + listed = run_slash('/mcp list global', rt) + assert '[off] docs' in listed.content + run_slash('/mcp remove docs global', rt) + ui = {m.name: m for m in webui.mcps.list_mcps('global')} + assert 'docs' not in ui + + def test_e3_stdio_splits_command_args(self, isolated_home): + rt = runtime_for(Path('.')) + run_slash( + '/mcp add fetch global command="npx -y @mcp/server-fetch"', rt) + mcp_json = json.loads( + (isolated_home / 'mcp.json').read_text(encoding='utf-8')) + servers = mcp_json.get('mcpServers') or mcp_json + assert servers['fetch']['command'] == 'npx' + assert servers['fetch']['args'] == ['-y', '@mcp/server-fetch'] + + +# --------------------------------------------------------------------------- +# F / period 4 — skills +# --------------------------------------------------------------------------- + + +class TestSkillsLedger: + def test_f1_f4_import_remove_keeps_source(self, isolated_home, + tmp_path, webui): + src = tmp_path / 'demo-skill' + src.mkdir() + (src / 'SKILL.md').write_text( + '---\nname: demo-skill\n---\n# Demo\n', encoding='utf-8') + rt = runtime_for(tmp_path / 'work') + (tmp_path / 'work').mkdir() + added = run_slash(f'/skills add {src} global', rt) + assert 'Imported' in added.content + dest = isolated_home / 'skills' / 'demo-skill' / 'SKILL.md' + assert dest.is_file() + assert (src / 'SKILL.md').is_file() + ui_names = {s.name for s in webui.skills.list_skills('global')} + assert 'demo-skill' in ui_names + removed = run_slash('/skills remove demo-skill global', rt) + assert 'Removed managed skill' in removed.content + assert not dest.parent.exists() + assert (src / 'SKILL.md').is_file() + + def test_f5_remove_auto_discovered_refuses_rmtree( + self, isolated_home, tmp_path, work_dir): + discovered = work_dir / '.agents' / 'skills' / 'local-skill' + discovered.mkdir(parents=True) + (discovered / 'SKILL.md').write_text('# local\n', encoding='utf-8') + rt = runtime_for(work_dir) + result = run_slash('/skills remove local-skill global', rt) + assert 'not a managed skill' in result.content.lower() + assert (discovered / 'SKILL.md').is_file() + + +# --------------------------------------------------------------------------- +# G / period 5 — memory +# --------------------------------------------------------------------------- + + +class TestMemoryLedger: + def test_g1_g3_global_default_inherits_on_new_folder( + self, isolated_home, tmp_path, work_dir): + status = run_slash('/memory') + assert 'Global default:' in status.content + assert 'Project:' in status.content + run_slash('/memory global on') + data = _settings(isolated_home) + assert data['personalization']['memory_enabled'] is True + existing = TuiApp._open_project(str(work_dir)) + run_slash('/memory global off') + # Already-registered project must not flip with the global default. + from ms_agent.project.manager import ProjectManager + still = ProjectManager(base_dir=str(isolated_home)).get(existing.id) + assert still.memory_enabled == existing.memory_enabled + run_slash('/memory global on') + fresh = tmp_path / 'brand-new' + fresh.mkdir() + inherited = TuiApp._open_project(str(fresh)) + assert inherited.memory_enabled is True + + def test_g2_g4_project_toggle_webui_and_config( + self, isolated_home, work_dir, webui): + TuiApp._open_project(str(work_dir)) + rt = runtime_for(work_dir) + result = run_slash('/memory on', rt) + assert 'Project memory → on' in result.content + from ms_agent.project.manager import ProjectManager + project = ProjectManager(base_dir=str(isolated_home)).find_by_path( + str(work_dir)) + assert project.memory_enabled is True + assert rt._memory_pending is True + assert OmegaConf.select( + rt.config, 'memory.unified_memory', default=None) is None + ui = webui.projects.get_project(project.id) + assert ui.memory_enabled is True + + def test_g5_vector_does_not_silent_file_fallback( + self, isolated_home, work_dir): + from ms_agent.project.manager import ProjectManager + TuiApp._open_project(str(work_dir)) + rt = runtime_for(work_dir) + run_slash('/memory backend vector', rt) + data = _settings(isolated_home) + assert data['personalization']['memory_backend'] == 'vector' + pm = ProjectManager(base_dir=str(isolated_home)) + project = pm.find_by_path(str(work_dir)) + assert project.memory_backend != 'vector' + run_slash('/memory project backend vector', rt) + project = pm.find_by_path(str(work_dir)) + assert project.memory_backend == 'vector' + run_slash('/memory on', rt) + assert OmegaConf.select(rt.config, 'memory', default=None) is None + memory_md = work_dir / '.ms_agent' / 'memory' / 'MEMORY.md' + assert not memory_md.exists() + + def test_g6_webui_enables_project_memory_for_tui( + self, isolated_home, work_dir, webui): + from app.schemas.project import ProjectCreate, ProjectUpdate + created = webui.projects.create_project( + ProjectCreate( + name='mem', + local_path=str(work_dir), + memory_enabled=True, + memory_backend='file', + )) + webui.projects.update_project( + created.id, ProjectUpdate(memory_enabled=True)) + tui = TuiApp._open_project(str(work_dir)) + assert tui.id == created.id + assert tui.memory_enabled is True + status = run_slash('/memory', runtime_for(work_dir)) + assert 'Project: on' in status.content + + def test_g7_disable_arms_the_next_message(self, isolated_home, work_dir): + TuiApp._open_project(str(work_dir)) + rt = runtime_for(work_dir) + rt.memory_tools = ['already-loaded'] + result = run_slash('/memory off', rt) + assert 'Project memory → off' in result.content + assert '/new' not in result.content + assert rt._memory_pending is True + + +class TestHttpEnvelope: + def test_tui_mcp_is_visible_over_webui_http(self, isolated_home, + webui_client): + run_slash( + '/mcp add docs global url=https://example.invalid/mcp', + runtime_for(Path('.'))) + resp = webui_client.get('/api/mcps', params={'scope': 'global'}) + assert resp.status_code == 200 + body = resp.json() + assert body['code'] == 0 + names = [row['name'] for row in body['data']] + assert 'docs' in names + docs = next(row for row in body['data'] if row['name'] == 'docs') + assert docs['endpoint'] == 'https://example.invalid/mcp' + dumped = json.dumps(body) + assert 'sk-' not in dumped diff --git a/tests/e2e/test_tui_webui_usability.py b/tests/e2e/test_tui_webui_usability.py new file mode 100644 index 000000000..069610b25 --- /dev/null +++ b/tests/e2e/test_tui_webui_usability.py @@ -0,0 +1,365 @@ +"""Usability e2e: prompts, defaults, and acceptance-doc conflicts. + +These tests are written from a user/developer seat, not from the +implementation. A case marked ``xfail`` is a product issue we want fixed; +unmarked failures mean a regression. Comments that mention ``e2e.md`` call +out pass criteria that are themselves misleading. +""" +from __future__ import annotations + +import json + +import pytest + +from ms_agent.project.manager import ProjectManager +from ms_agent.tui.app import TuiApp + +from tests.e2e.helpers import ModelRuntime, runtime_for, run_slash + + +def _assert_live_home(content: str, home) -> None: + """Help shows the resolved path, not a hardcoded home or another product.""" + home = str(home) + assert '~/.ms_agent' not in content, ( + f'help still says ~/.ms_agent while the live home is {home}') + assert home in content, ( + f'help should show the live path {home} so a person can open the file') + assert 'WebUI' not in content + assert 'MS_AGENT_HOME' not in content + + +class TestHelpTextLiesAboutHome: + """Slash help shows the live config path, not ``~/.ms_agent`` or WebUI.""" + + @pytest.mark.usability + def test_search_help_mentions_effective_home(self, isolated_home): + _assert_live_home(run_slash('/search').content, isolated_home) + + @pytest.mark.usability + def test_instruction_help_mentions_effective_home( + self, isolated_home, work_dir): + _assert_live_home( + run_slash('/instruction', runtime_for(work_dir)).content, + isolated_home) + + @pytest.mark.usability + def test_profile_help_mentions_effective_home(self, isolated_home): + _assert_live_home(run_slash('/profile').content, isolated_home) + + @pytest.mark.usability + def test_mcp_help_mentions_effective_home(self, isolated_home): + _assert_live_home(run_slash('/mcp help').content, isolated_home) + + @pytest.mark.usability + def test_skills_help_mentions_effective_home(self, isolated_home): + _assert_live_home(run_slash('/skills help').content, isolated_home) + + @pytest.mark.usability + def test_model_help_mentions_effective_home( + self, isolated_home, work_dir): + _assert_live_home( + run_slash('/model help', ModelRuntime(work_dir)).content, + isolated_home) + + +class TestBareCommandShape: + """Bare ``/cmd`` is the current status, then the usage sheet. + + ``/cmd help`` is usage only. Action commands (/new, /quit, /stop, + /compact) and dumps (/help, /config) stay as they are. + """ + + @pytest.mark.usability + def test_bare_skills_is_status_then_usage(self, isolated_home): + result = run_slash('/skills') + assert result.content.strip().startswith('No skills.') or result.content.strip().startswith('Skills:') + assert '/skills list' in result.content + assert '/skills add' in result.content + assert result.content.lower().index('usage:') > 0 + + @pytest.mark.usability + def test_bare_mcp_is_status_then_usage(self, isolated_home): + result = run_slash('/mcp') + assert result.content.strip().startswith('No MCP servers.') or result.content.startswith('MCP servers') + assert '/mcp list' in result.content + assert result.content.lower().index('usage:') > 0 + + @pytest.mark.usability + def test_skills_list_without_runtime_shows_imported_ids( + self, isolated_home, tmp_path, work_dir): + src = tmp_path / 'shown-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# Shown\n', encoding='utf-8') + rt = runtime_for(work_dir) + run_slash(f'/skills add {src} global', rt) + listed = run_slash('/skills list', rt) + assert 'shown-skill' in listed.content + assert 'Sources:' not in listed.content + + +class TestDefaultScopeFootgun: + """``/mcp add`` omits to this folder (project) and says so. + + ``global`` is every folder on this machine. A new ``/skills add`` lands + on this machine; enable, disable, and remove follow the copy that + already exists. + """ + + @pytest.mark.usability + def test_mcp_add_without_scope_is_project_not_global( + self, isolated_home, work_dir, webui): + TuiApp._open_project(str(work_dir)) + rt = runtime_for(work_dir) + result = run_slash( + '/mcp add docs url=https://example.invalid/mcp', rt) + assert 'Added docs' in result.content + assert '(project)' in result.content + global_names = {m.name for m in webui.mcps.list_mcps('global')} + assert 'docs' not in global_names + project = ProjectManager(base_dir=str(isolated_home)).find_by_path( + str(work_dir)) + project_names = { + m.name + for m in webui.mcps.list_mcps(f'project:{project.id}') + } + assert 'docs' in project_names + + @pytest.mark.usability + def test_mcp_add_without_scope_warns_about_settings_page( + self, isolated_home, work_dir): + TuiApp._open_project(str(work_dir)) + result = run_slash( + '/mcp add docs url=https://example.invalid/mcp', + runtime_for(work_dir)) + lowered = result.content.lower() + assert 'this folder' in lowered + assert 'global' in lowered + assert 'webui' not in lowered + + @pytest.mark.usability + def test_skills_add_lands_on_this_machine_and_disable_follows_it( + self, isolated_home, work_dir): + """A new slash skill is global. Later commands follow that copy.""" + TuiApp._open_project(str(work_dir)) + rt = runtime_for(work_dir) + src = work_dir / 'scoped-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# scoped\n', encoding='utf-8') + added = run_slash(f'/skills add {src}', rt) + assert 'Imported' in added.content + assert '(global)' in added.content + assert (isolated_home / 'skills' / 'scoped-skill').is_dir() + assert not (work_dir / '.ms_agent' / 'skills' / 'scoped-skill').exists() + disabled = run_slash('/skills disable scoped-skill', rt) + assert 'disable scoped-skill (global)' in disabled.content + + +class TestModelSwitchShadowsWebuiDefault: + """e2e.md A5 / 0.5 tell testers to delete ``/.ms_agent/config.yaml`` + before checking that TUI follows the WebUI default. That file is created + by the documented way to switch models (B2 ``/model ``). + + The pass criterion is therefore self-defeating: exercising B2 makes A5 + fail on the same work dir. Users who switch once in TUI can never pick + up a later WebUI default for that folder. + """ + + @pytest.mark.usability + def test_later_webui_default_wins_over_old_tui_switch( + self, isolated_home, work_dir, stub_llm_rebuild, webui): + rt = ModelRuntime(work_dir) + TuiApp._open_project(str(work_dir)) + run_slash( + '/model provider add acme key=sk-acme ' + 'url=https://example.invalid/v1 protocol=openai', + rt) + run_slash('/model catalog add acme pinned-in-tui', rt) + switched = run_slash('/model acme/pinned-in-tui', rt) + assert 'pinned-in-tui' in switched.content + patch = work_dir / '.ms_agent' / 'config.yaml' + assert not patch.is_file() + + from app.backends.ms_agent import model_link + model_link.set_active_model('openai', 'qwen3.7-plus') + cfg = TuiApp._load_runtime_config( + 'unused.yaml', str(work_dir), explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work_dir)) + assert cfg.llm.model == 'qwen3.7-plus', ( + f'TUI still pinned to {cfg.llm.model} via {patch} after WebUI ' + 'changed the default') + assert cfg.llm.service == 'openai' + + @pytest.mark.usability + def test_switch_does_not_write_a_project_patch( + self, isolated_home, work_dir, stub_llm_rebuild): + rt = ModelRuntime(work_dir) + run_slash( + '/model provider add acme key=sk-acme ' + 'url=https://example.invalid/v1 protocol=openai', + rt) + run_slash('/model catalog add acme a-1', rt) + result = run_slash('/model acme/a-1', rt) + assert 'a-1' in result.content + assert 'Saved as the default' not in result.content + assert '下一条消息使用它' not in result.content + assert 'project patch' not in result.content.lower() + assert not (work_dir / '.ms_agent' / 'config.yaml').exists() + + +class TestBareShowsStatusThenUsage: + """Bare ``/model`` / ``/search`` show the current value first, then usage.""" + + @pytest.mark.usability + def test_bare_model_is_status_then_usage(self, isolated_home, + work_dir, + stub_llm_rebuild): + result = run_slash('/model', ModelRuntime(work_dir)) + text = result.content + assert 'Provider:' in text or 'Model:' in text + assert 'usage:' in text.lower() + status_at = min( + i for i in (text.find('Provider:'), text.find('Model:')) if i >= 0) + assert status_at < text.lower().index('usage:') + + @pytest.mark.usability + def test_bare_search_is_status_then_usage(self, isolated_home): + result = run_slash('/search') + text = result.content + assert 'Engine:' in text + assert 'usage:' in text.lower() + assert text.index('Engine:') < text.lower().index('usage:') + + +class TestMemoryBackendSemantics: + """``/memory global on`` does not touch the current project. + ``/memory backend`` is the same: omit-scope writes only the global + default. The current folder needs ``/memory project backend``. + """ + + @pytest.mark.usability + def test_backend_without_scope_does_not_rewrite_existing_project( + self, isolated_home, work_dir): + TuiApp._open_project(str(work_dir)) + rt = runtime_for(work_dir) + run_slash('/memory project backend file', rt) + # Same command family, still in the project: omit-scope should only + # change the global default for *new* folders, like `/memory global on`. + result = run_slash('/memory backend vector', rt) + assert 'this project is unchanged' in result.content + project = ProjectManager(base_dir=str(isolated_home)).find_by_path( + str(work_dir)) + data = json.loads((isolated_home / 'settings.json').read_text()) + assert data['personalization']['memory_backend'] == 'vector' + assert project.memory_backend == 'file' + + @pytest.mark.usability + def test_project_backend_updates_only_this_folder( + self, isolated_home, work_dir): + TuiApp._open_project(str(work_dir)) + rt = runtime_for(work_dir) + run_slash('/memory backend file', rt) + result = run_slash('/memory project backend vector', rt) + assert 'Project memory backend → vector' in result.content + project = ProjectManager(base_dir=str(isolated_home)).find_by_path( + str(work_dir)) + data = json.loads((isolated_home / 'settings.json').read_text()) + assert data['personalization']['memory_backend'] == 'file' + assert project.memory_backend == 'vector' + + +class TestWebuiPartialPut: + """PUT /api/agent-settings uses a full AgentSettings model whose + ``default_memory_enabled`` defaults to True. A partial PUT that only + changes the default model would silently turn memory on. + """ + + @pytest.mark.usability + def test_partial_agent_settings_put_does_not_enable_memory( + self, isolated_home, webui_client): + before = webui_client.get('/api/agent-settings').json()['data'] + assert before['default_memory_enabled'] is False + # Only send a model field. A safe API must treat omitted booleans + # as "leave unchanged", not "schema default". + resp = webui_client.put('/api/agent-settings', json={}) + assert resp.status_code == 200 + after = resp.json()['data'] + assert after['default_memory_enabled'] is False + + +class TestWebuiKeyMaskMatchesTui: + """A configured key uses the WebUI rule on both sides: eight characters + or fewer are ``****``, longer keys keep the first and last four. + """ + + @pytest.mark.usability + def test_webui_provider_mask_matches_tui_list( + self, isolated_home, work_dir, stub_llm_rebuild, webui): + secret = 'sk-test-acme' + run_slash( + f'/model provider add acme key={secret} ' + 'url=https://example.invalid/v1 protocol=openai', + ModelRuntime(work_dir)) + listed = run_slash('/model list', ModelRuntime(work_dir)) + row = next(p for p in webui.providers.list_providers() if p.id == 'acme') + masked = row.api_key_masked or '' + assert secret not in masked + assert secret not in listed.content + assert masked == 'sk-t****acme' + assert 'key=sk-t****acme' in listed.content + + +class TestResumeIndexVsMarker: + """Session #0 is rendered as ➤ when it is current, so the usage line + ``/resume <#|id>`` does not match what the user sees in the table. + Index 0 must still work. + """ + + @pytest.mark.usability + def test_resume_zero_still_selects_first_session(self, isolated_home, + work_dir): + from ms_agent.project import SessionManager + + project = TuiApp._open_project(str(work_dir)) + sm = SessionManager(project) + first = sm.create(name='alpha') + second = sm.create(name='beta') + app = TuiApp.__new__(TuiApp) + app._sm = sm + app.session = first + listed = sm.list() # newest first + assert listed[0].id == second.id + assert app._resume_target('0').id == listed[0].id + assert app._resume_target('1').id == listed[1].id + assert app._resume_target(first.id).id == first.id + + +class TestMissingKeyPrompt: + def test_setup_text_names_the_provider_and_one_command(self): + from ms_agent.llm.credentials import missing_api_key_setup_text + text = missing_api_key_setup_text( + ValueError('No API key found for provider "openai"')) + assert text == ('还没有 openai 的密钥。\n\n' + ' /model provider key openai <密钥>') + + +class TestDocZeroFiveIsUnreasonable: + """Period 0.5 used to tell testers to move + ``/.ms_agent/config.yaml`` out of the way. That file is no longer + written by ``/model``; a later WebUI default is what the next TUI launch + uses (no --config). + """ + + @pytest.mark.usability + def test_doc_should_not_require_deleting_the_file_the_product_writes( + self, isolated_home, work_dir, stub_llm_rebuild): + rt = ModelRuntime(work_dir) + run_slash( + '/model provider add acme key=sk-acme ' + 'url=https://example.invalid/v1 protocol=openai', + rt) + run_slash('/model catalog add acme pinned', rt) + run_slash('/model acme/pinned', rt) + assert not (work_dir / '.ms_agent' / 'config.yaml').exists() + cfg2 = TuiApp._load_runtime_config( + 'unused.yaml', str(work_dir), explicit_config=False) + assert cfg2.llm.model == 'pinned' diff --git a/tests/llm/test_model_discovery.py b/tests/llm/test_model_discovery.py new file mode 100644 index 000000000..ccd9c6510 --- /dev/null +++ b/tests/llm/test_model_discovery.py @@ -0,0 +1,236 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Unit tests for /models discovery and chat-oriented filtering (no network).""" +from unittest.mock import MagicMock, patch + +from ms_agent.llm.model_discovery import ( + fetch_model_ids, + filter_chat_model_ids, + format_id_list, + is_non_chat_model, + parse_model_ids, + wire_protocol, +) + + +def test_parse_model_ids_openai_shape(): + ids = parse_model_ids({ + 'data': [ + {'id': 'qwen-plus'}, + {'id': 'qwen-plus'}, + {'object': 'model'}, + 'skip', + ] + }) + assert ids == ['qwen-plus'] + + +def test_parse_model_ids_garbage(): + assert parse_model_ids(None) == [] + assert parse_model_ids({'data': 'nope'}) == [] + + +def test_filter_keeps_chat_and_vision_drops_media(): + raw = [ + 'qwen-plus', + 'qwen3.8-flash', + 'qwen-vl-max', + 'glm-4v', + 'gpt-4o', + 'wanx-v1', + 'wan2.1-t2v-plus', + 'qwen-image-plus', + 'text-embedding-v3', + 'tts-1', + 'dall-e-3', + 'video-01', + ] + kept, dropped = filter_chat_model_ids(raw) + assert kept == [ + 'qwen-plus', + 'qwen3.8-flash', + 'qwen-vl-max', + 'glm-4v', + 'gpt-4o', + ] + assert dropped == 7 + assert not is_non_chat_model('qwen-vl-plus') + assert is_non_chat_model('cogview-3') + assert is_non_chat_model('MiniMax/speech-02-hd') + + +def test_format_live_model_lines_groups_by_family_and_owner(): + from ms_agent.llm.model_discovery import format_live_model_lines + lines = format_live_model_lines( + [ + 'MiniMax-M2.1', + 'MiniMax-M2.5', + 'MiniMax/MiniMax-M2.1', + 'ZHIPU/GLM-5', + 'deepseek-r1', + 'deepseek-v3', + 'gui-plus', + 'qwen-plus', + 'qwen3.8-flash', + 'qwen-vl-max', + ], + indent=' ', + ) + text = '\n'.join(lines) + assert ' MiniMax (2)' in lines or ' MiniMax (2)' in text + assert ' MiniMax-M2.1' in lines + assert ' MiniMax/ (1)' in lines + assert ' MiniMax/MiniMax-M2.1' in lines + assert ' ZHIPU/ (1)' in lines + assert ' ZHIPU/GLM-5' in lines + assert ' qwen (3)' in lines + assert ' qwen-plus' in lines + assert ' qwen3.8-flash' in lines + assert ' gui-plus' in lines + assert ' deepseek (2)' in lines + # One id per line — no comma-joined blob. + assert not any(',' in line and 'qwen-plus' in line for line in lines) + + +def test_format_id_list_caps(): + ids = [f'm{i}' for i in range(5)] + assert format_id_list(ids, limit=3) == 'm0, m1, m2 … +2 more' + assert format_id_list([], limit=3) == '(none)' + + +def test_wire_protocol(): + assert wire_protocol('anthropic_messages') == 'anthropic' + assert wire_protocol('openai_compat') == 'openai' + + +def test_fetch_model_ids_ok(): + payload = {'data': [{'id': 'a'}, {'id': 'b'}]} + resp = MagicMock(status_code=200) + resp.json.return_value = payload + client = MagicMock() + client.get.return_value = resp + client.__enter__.return_value = client + client.__exit__.return_value = False + with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client): + ids = fetch_model_ids('https://example.invalid/v1', 'openai', 'sk') + assert ids == ['a', 'b'] + client.get.assert_called_once() + args, kwargs = client.get.call_args + assert args[0] == 'https://example.invalid/v1/models' + assert kwargs['headers']['Authorization'] == 'Bearer sk' + + +def test_probe_405_and_404_are_no_catalog(): + from ms_agent.llm.model_discovery import probe_model_catalog + client = MagicMock() + client.__enter__.return_value = client + client.__exit__.return_value = False + with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client): + for code in (404, 405): + client.get.return_value = MagicMock(status_code=code) + probe = probe_model_catalog( + 'https://example.invalid/v1', 'openai', 'sk') + assert probe.outcome == 'no_catalog' + assert probe.status == code + assert probe.ids == [] + + +def test_probe_keeps_auth_status(): + from ms_agent.llm.model_discovery import probe_model_catalog + resp = MagicMock(status_code=401) + client = MagicMock() + client.get.return_value = resp + client.__enter__.return_value = client + client.__exit__.return_value = False + with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client): + probe = probe_model_catalog('https://example.invalid/v1', 'openai', 'sk') + assert probe.outcome == 'auth' + assert probe.status == 401 + assert probe.ids == [] + + +def test_probe_empty_body_is_not_a_missing_id(): + from ms_agent.llm.model_discovery import probe_model_catalog + resp = MagicMock(status_code=200) + resp.json.return_value = {'unexpected': True} + client = MagicMock() + client.get.return_value = resp + client.__enter__.return_value = client + client.__exit__.return_value = False + with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client): + probe = probe_model_catalog('https://example.invalid/v1', 'openai', 'sk') + assert probe.outcome == 'no_catalog' + assert probe.status == 200 + + +def test_probe_timeout_is_unreachable(): + import httpx + from ms_agent.llm.model_discovery import probe_model_catalog + client = MagicMock() + client.get.side_effect = httpx.ConnectError('nope') + client.__enter__.return_value = client + client.__exit__.return_value = False + with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client): + probe = probe_model_catalog('https://example.invalid/v1', 'openai', 'sk') + assert probe.outcome == 'unreachable' + assert probe.status is None + + +def test_chat_unsupported_stays_on_modality_phrases(): + from ms_agent.llm.model_discovery import chat_failed_because_unsupported + missed = ( + 'gemini-2.5-flash-image-preview', + 'lumina-image-2.0', + 'ideogram-v3', + 'midjourney-v7', + 'recraft-v3', + 'playground-v2.5-1024px-aesthetic', + 'hidream-i1-full', + 'seedream-4.0', + 'nano-banana', + 'veo3', + 'luma-dream-machine', + 'runway-gen4', + 'pika-2.2', + 'minimax-hailuo-02', + 'z-image-turbo', + 'unisound-u2', + 'chattts', + 'bge-m3', + 'm3e-base', + ) + for name in missed: + assert not chat_failed_because_unsupported(name, '') + invalid = "Error code: 400 - {'error':{'message':'Invalid model id'}}" + assert not chat_failed_because_unsupported('z-image-turbo', invalid) + assert not chat_failed_because_unsupported('qwen-vl-max', invalid) + assert not chat_failed_because_unsupported('qwen-plus', '401 Unauthorized') + assert chat_failed_because_unsupported( + 'qwen-plus', 'This endpoint only supports embeddings') + assert chat_failed_because_unsupported('wanx-v1', '') + + +def test_rejected_model_id_is_separate_from_cannot_chat(): + from ms_agent.llm.model_discovery import model_id_rejected + assert model_id_rejected( + "Error code: 400 - {'error':{'message':'Invalid model id'}}") + assert model_id_rejected('Error code: 400 - Model does not exist') + assert model_id_rejected('Error code: 404 - The model `x` does not exist') + assert model_id_rejected('model_not_supported') + assert model_id_rejected( + "Error code: 400 - {'error': {'message': " + "'Model id : Qwen/old , has no provider supported'}}") + assert not model_id_rejected('401 Unauthorized') + assert not model_id_rejected('403 Forbidden') + assert not model_id_rejected('does not exist') + assert not model_id_rejected('This endpoint only supports embeddings') + assert not model_id_rejected('') + + +def test_fetch_model_ids_non_2xx(): + resp = MagicMock(status_code=401) + client = MagicMock() + client.get.return_value = resp + client.__enter__.return_value = client + client.__exit__.return_value = False + with patch('ms_agent.llm.model_discovery.httpx.Client', return_value=client): + assert fetch_model_ids('https://example.invalid/v1', 'openai', 'sk') == [] diff --git a/tests/llm/test_provider_router_live.py b/tests/llm/test_provider_router_live.py index ba92e51ab..7888f458e 100644 --- a/tests/llm/test_provider_router_live.py +++ b/tests/llm/test_provider_router_live.py @@ -47,7 +47,7 @@ # service -> (default model, env key that must be present) PROVIDERS = { - 'modelscope': ('Qwen/Qwen3-235B-A22B-Instruct-2507', 'MODELSCOPE_API_KEY'), + 'modelscope': ('Qwen/Qwen3.8-Flash-Next', 'MODELSCOPE_API_KEY'), 'dashscope': ('qwen3.7-plus', 'DASHSCOPE_API_KEY'), 'deepseek': ('deepseek-chat', 'DEEPSEEK_API_KEY'), 'zhipu': ('glm-4.5', 'GLM_API_KEY'), diff --git a/tests/mcp/test_mcp_runtime.py b/tests/mcp/test_mcp_runtime.py index 27911909f..5c3c017db 100644 --- a/tests/mcp/test_mcp_runtime.py +++ b/tests/mcp/test_mcp_runtime.py @@ -37,14 +37,15 @@ def is_connected(self, server_name: str) -> bool: def list_connected_servers(self) -> list[str]: return list(self.sessions.keys()) - async def connect_single_server(self, server_name: str, server_config: dict): + async def connect_single_server(self, server_name: str, server_config: dict, + **kwargs): self.connect_calls.append(server_name) if server_config.get('fail_connect') or server_config.get('command') == 'x': raise ConnectionError(f'connect failed: {server_name}') self.sessions[server_name] = object() return server_name - async def disconnect_server(self, server_name: str): + async def disconnect_server(self, server_name: str, **kwargs): self.sessions.pop(server_name, None) async def get_tools_for_server(self, server_name: str) -> List[Tool]: @@ -73,6 +74,10 @@ async def get_tools(self) -> Dict[str, List[Tool]]: async def call_tool(self, server_name: str, tool_name: str, tool_args: dict): self.call_tool_calls += 1 + if getattr(self, 'call_cancel', False): + raise asyncio.CancelledError() + if getattr(self, 'call_group', False): + raise ExceptionGroup('mcp', [ConnectionError('broken pipe')]) if getattr(self, 'call_raises', False): raise ConnectionError('broken pipe') if getattr(self, 'call_timeout', False): @@ -435,6 +440,120 @@ def test_classify_mcp_failure(): assert classify_mcp_failure(ConnectionError('x')) == 'hard' assert classify_mcp_failure(BrokenPipeError()) == 'hard' assert classify_mcp_failure(ValueError('bad arg')) == 'none' + grouped = ExceptionGroup('mcp', [ConnectionError('broken pipe')]) + assert classify_mcp_failure(grouped) == 'hard' + + +@pytest.mark.asyncio +async def test_dead_server_group_is_a_tool_error_not_a_failed_turn(): + client = FakeMCPClient() + client.call_group = True + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=_resolved(('fetch', {'command': 'echo'})), + owns_client=False, + ) + tm = _make_tool_manager(client, runtime) + await tm.connect() + runtime.bind_tool_manager(tm) + await runtime.start() + await runtime.sync_tools() + + result = await tm.single_call_tool({ + 'tool_name': 'fetch---demo_tool', + 'arguments': {}, + }) + assert result['is_error'] is True + assert runtime.get_server('fetch').status == 'degraded' + + +@pytest.mark.asyncio +async def test_cancelled_tool_call_still_propagates(): + client = FakeMCPClient() + client.call_cancel = True + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=_resolved(('fetch', {'command': 'echo'})), + owns_client=False, + ) + tm = _make_tool_manager(client, runtime) + await tm.connect() + runtime.bind_tool_manager(tm) + await runtime.start() + await runtime.sync_tools() + + with pytest.raises(asyncio.CancelledError): + await tm.single_call_tool({ + 'tool_name': 'fetch---demo_tool', + 'arguments': {}, + }) + + +@pytest.mark.asyncio +async def test_disconnect_notice_once_when_server_becomes_unusable(): + from ms_agent.agent.llm_agent import LLMAgent + + client = FakeMCPClient() + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=_resolved(('fetch', {'command': 'echo'})), + owns_client=False, + ) + await runtime.start() + agent = LLMAgent.__new__(LLMAgent) + agent.mcp_runtime = runtime + + class _Sink: + def __init__(self): + self.texts = [] + + def emit(self, event): + self.texts.append(event.text) + + agent._event_sink = _Sink() + exc = ConnectionError('connection closed') + await agent._on_mcp_failure( + 'fetch', 'call_tool', 'connection closed', exc=exc) + await agent._on_mcp_failure( + 'fetch', 'call_tool', 'connection closed', exc=exc) + assert agent._event_sink.texts == [ + 'MCP 服务器 fetch 连接断开,本轮对话无法使用。自动尝试重连中。' + ] + assert agent._mcp_fp is None + assert 'fetch' in agent._mcp_reconnect_names + + +@pytest.mark.asyncio +async def test_timeout_notice_waits_for_the_degrade_threshold(): + from ms_agent.agent.llm_agent import LLMAgent + + client = FakeMCPClient() + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=_resolved(('fetch', {'command': 'echo'})), + owns_client=False, + ) + await runtime.start() + agent = LLMAgent.__new__(LLMAgent) + agent.mcp_runtime = runtime + + class _Sink: + def __init__(self): + self.texts = [] + + def emit(self, event): + self.texts.append(event.text) + + agent._event_sink = _Sink() + for _ in range(DEGRADED_FAILURE_THRESHOLD - 1): + await agent._on_mcp_failure( + 'fetch', 'call_tool', 'timeout', exc=TimeoutError('timeout')) + assert agent._event_sink.texts == [] + await agent._on_mcp_failure( + 'fetch', 'call_tool', 'timeout', exc=TimeoutError('timeout')) + assert agent._event_sink.texts == [ + 'MCP 服务器 fetch 连接断开,本轮对话无法使用。自动尝试重连中。' + ] def test_is_connection_error(): @@ -578,3 +697,135 @@ class _Config: ) tm._skip_mcp_reindex = True return tm + + +@pytest.mark.asyncio +async def test_start_probes_servers_together(monkeypatch): + monkeypatch.setattr('ms_agent.mcp.runtime.MCP_PROBE_TIMEOUT', 1) + + class SlowClient(FakeMCPClient): + + async def connect_single_server(self, server_name: str, server_config: dict, + **kwargs): + await asyncio.sleep(0.2) + return await super().connect_single_server( + server_name, server_config, **kwargs) + + client = SlowClient() + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=_resolved( + ('a', {'command': 'echo'}), + ('b', {'command': 'echo'}), + ), + owns_client=False, + ) + started = asyncio.get_running_loop().time() + await runtime.start() + elapsed = asyncio.get_running_loop().time() - started + assert elapsed < 0.35 + assert runtime.get_server('a').status == 'connected' + assert runtime.get_server('b').status == 'connected' + + +@pytest.mark.asyncio +async def test_start_skips_a_server_that_does_not_answer(monkeypatch): + monkeypatch.setattr('ms_agent.mcp.runtime.MCP_PROBE_TIMEOUT', 0.05) + + class HungClient(FakeMCPClient): + + async def connect_single_server(self, server_name: str, server_config: dict, + **kwargs): + if server_name == 'slow': + await asyncio.Event().wait() + return await super().connect_single_server( + server_name, server_config, **kwargs) + + client = HungClient() + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=_resolved( + ('slow', {'command': 'echo'}), + ('good', {'command': 'echo'}), + ), + owns_client=False, + ) + started = asyncio.get_running_loop().time() + await runtime.start() + elapsed = asyncio.get_running_loop().time() - started + assert elapsed < 0.5 + assert runtime.get_server('good').status == 'connected' + slow = runtime.get_server('slow') + assert slow.status == 'error' + assert slow.last_error == '0.05秒内没有连上' + assert client.is_connected('good') + assert not client.is_connected('slow') + + +@pytest.mark.asyncio +async def test_apply_config_does_not_reprobe_a_recorded_failure(): + client = FakeMCPClient() + config = ResolvedMCPConfig(mcp_servers={ + 'bad': {'command': 'x'}, + 'good': {'command': 'echo'}, + }) + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=config, + connect_policy='skip', + owns_client=False, + ) + await runtime.start() + assert client.connect_calls.count('bad') == 1 + await runtime.apply_config(ResolvedMCPConfig(mcp_servers={ + 'bad': {'command': 'x'}, + 'good': {'command': 'echo'}, + 'extra': {'command': 'echo'}, + })) + assert client.connect_calls.count('bad') == 1 + assert client.is_connected('extra') + assert runtime.get_server('bad').status == 'error' + assert runtime.get_server('bad').last_error == 'connect failed: bad' + + +@pytest.mark.asyncio +async def test_apply_config_reconnects_a_degraded_server(): + client = FakeMCPClient() + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=_resolved(('fetch', {'command': 'echo'})), + owns_client=False, + ) + await runtime.start() + assert client.is_connected('fetch') + runtime.get_server('fetch').status = 'degraded' + before = len(client.connect_calls) + await runtime.apply_config(_resolved(('fetch', {'command': 'echo'}))) + assert len(client.connect_calls) > before + assert runtime.get_server('fetch').status == 'connected' + assert client.is_connected('fetch') + + +@pytest.mark.asyncio +async def test_apply_config_leaves_a_healthy_server_connected(): + client = FakeMCPClient() + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=_resolved( + ('fetch', {'command': 'echo'}), + ('docs', {'command': 'echo'}), + ), + owns_client=False, + ) + await runtime.start() + docs_session = client.sessions['docs'] + runtime.get_server('fetch').status = 'degraded' + await runtime.apply_config( + _resolved( + ('fetch', {'command': 'echo'}), + ('docs', {'command': 'echo'}), + )) + assert client.connect_calls.count('docs') == 1 + assert client.sessions['docs'] is docs_session + assert client.connect_calls.count('fetch') == 2 + assert runtime.get_server('fetch').status == 'connected' diff --git a/tests/memory/test_file_storage_notice.py b/tests/memory/test_file_storage_notice.py new file mode 100644 index 000000000..45cae8fed --- /dev/null +++ b/tests/memory/test_file_storage_notice.py @@ -0,0 +1,80 @@ +"""Memory tool results the user can act on.""" +import errno + +import pytest + +from ms_agent.memory.unified.backends.file_based import FileBasedBackend +from ms_agent.memory.unified.config import MemoryConfig +from ms_agent.memory.unified.storage import file_storage as storage_mod +from ms_agent.memory.unified.user_notice import ( + CHAR_LIMIT_NOTICE, + DISK_NOTICE, + MISSING_DIR_NOTICE, + NO_SPACE_NOTICE, + PERMISSION_NOTICE, +) + + +@pytest.mark.asyncio +async def test_char_limit_is_the_tool_result_and_does_not_stick(tmp_path): + backend = FileBasedBackend( + MemoryConfig(base_dir=str(tmp_path), char_limit=20)) + over = await backend.handle_tool_call('memory', { + 'action': 'add', + 'content': 'x' * 40, + }) + assert over == CHAR_LIMIT_NOTICE + assert not (tmp_path / 'MEMORY.md').exists() + + missed = await backend.handle_tool_call('memory', { + 'action': 'replace', + 'content': 'missing', + 'new_content': 'y', + }) + assert missed == '更新失败' + assert CHAR_LIMIT_NOTICE not in missed + + +@pytest.mark.asyncio +async def test_char_limit_is_logged_as_a_warning(tmp_path, monkeypatch): + seen = [] + + def _warning(msg, *args, **kwargs): + seen.append(msg % args if args else msg) + + monkeypatch.setattr(storage_mod.logger, 'warning', _warning) + backend = FileBasedBackend( + MemoryConfig(base_dir=str(tmp_path), char_limit=20)) + await backend.handle_tool_call('memory', { + 'action': 'add', + 'content': 'x' * 40, + }) + assert any('char limit' in msg for msg in seen) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('exc,notice', [ + (FileNotFoundError(errno.ENOENT, 'gone', 'MEMORY.md'), MISSING_DIR_NOTICE), + (PermissionError(errno.EACCES, 'denied', 'MEMORY.md'), PERMISSION_NOTICE), + (OSError(errno.ENOSPC, 'full', 'MEMORY.md'), NO_SPACE_NOTICE), + (OSError(errno.EIO, 'io', 'MEMORY.md'), DISK_NOTICE), +]) +async def test_disk_error_sentence_matches_the_cause(tmp_path, monkeypatch, + exc, notice): + + def boom(*_args, **_kwargs): + raise exc + + monkeypatch.setattr(storage_mod, 'atomic_write_text', boom) + backend = FileBasedBackend(MemoryConfig(base_dir=str(tmp_path))) + added = await backend.handle_tool_call('memory', { + 'action': 'add', + 'content': 'remember this', + }) + assert added == notice + + removed = await backend.handle_tool_call('memory', { + 'action': 'remove', + 'content': 'remember this', + }) + assert removed == notice diff --git a/tests/memory/test_orchestrator_scheduling.py b/tests/memory/test_orchestrator_scheduling.py index 01e70f929..6e0b1b1fe 100644 --- a/tests/memory/test_orchestrator_scheduling.py +++ b/tests/memory/test_orchestrator_scheduling.py @@ -21,6 +21,7 @@ from ms_agent.llm.utils import Message from ms_agent.memory.unified.config import MemoryConfig from ms_agent.memory.unified.orchestrator import MemoryOrchestrator +from ms_agent.memory.unified.user_notice import MISSING_DIR_NOTICE class RecordingBackend: @@ -139,9 +140,16 @@ async def main(): asyncio.run(main()) -def test_failed_ingest_is_retried_on_the_next_turn(tmp_path): +def test_failed_ingest_is_retried_on_the_next_turn(tmp_path, monkeypatch): async def main(): + logged = [] + + def _error(msg, *args, **kwargs): + logged.append(msg % args if args else msg) + + monkeypatch.setattr( + 'ms_agent.memory.unified.orchestrator.logger.error', _error) class Failing(RecordingBackend): @@ -152,6 +160,8 @@ async def on_messages(self, messages, **kwargs): await orch.add(_msgs('important fact', 'noted')) assert orch.ingest_status['state'] == 'error' assert 'provider down' in orch.ingest_status['error'] + assert orch.user_notice is None + assert any('provider down' in line for line in logged) # Hashes were NOT recorded, so the same messages come back as delta. backend = RecordingBackend() @@ -160,6 +170,36 @@ async def on_messages(self, messages, **kwargs): assert [m['content'] for m in backend.batches[0]] == [ 'important fact', 'noted' ] + assert orch.user_notice is None + + asyncio.run(main()) + + +def test_missing_memory_dir_uses_its_own_sentence(tmp_path): + + async def main(): + + class Missing(RecordingBackend): + + async def on_messages(self, messages, **kwargs): + raise FileNotFoundError(2, 'gone', 'MEMORY.md') + + orch = _orch(tmp_path, Missing()) + await orch.add(_msgs('q', 'a')) + assert orch.user_notice == MISSING_DIR_NOTICE + + asyncio.run(main()) + + +def test_later_success_clears_a_stale_notice(tmp_path): + + async def main(): + backend = RecordingBackend() + orch = _orch(tmp_path, backend) + await orch.add(_msgs('q', 'a')) + orch.user_notice = 'stale disk failure' + await orch.add(_msgs('q', 'a')) + assert orch.user_notice is None asyncio.run(main()) diff --git a/tests/permission/test_memory.py b/tests/permission/test_memory.py index d2af175a2..677a82cac 100644 --- a/tests/permission/test_memory.py +++ b/tests/permission/test_memory.py @@ -103,3 +103,12 @@ def test_corrupt_file(self, tmp_path): mem = PermissionMemory(project_path=project_path) assert mem.list_all() == [] + + def test_default_global_file_follows_ms_agent_home(self, tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + mem = PermissionMemory(project_path=None) + mem.add('web_search---*', scope='global') + assert (home / 'permission_memory.json').is_file() + reloaded = PermissionMemory(project_path=None) + assert reloaded.matches('web_search---fetch_page', {}) diff --git a/tests/project/test_manager.py b/tests/project/test_manager.py index e1ded85f4..213c115e6 100644 --- a/tests/project/test_manager.py +++ b/tests/project/test_manager.py @@ -170,3 +170,30 @@ def test_open_folder_roundtrips_via_get(self, pm, tmp_path): assert again is not None assert again.path == project.path assert again.instruction == 'be terse' + + def test_find_by_path_returns_create_project(self, pm, tmp_path): + folder = tmp_path / 'webui-created' + folder.mkdir() + created = pm.create(name='From WebUI', path=str(folder), + init_workspace=False) + found = pm.find_by_path(str(folder)) + assert found is not None + assert found.id == created.id + + def test_open_folder_reuses_create_project_id(self, pm, tmp_path): + """TUI --work-dir on a WebUI-created project must not mint a path-key.""" + from ms_agent.project.paths import project_key + folder = tmp_path / 'shared-workspace' + folder.mkdir() + created = pm.create( + name='WebUI', path=str(folder), init_workspace=False) + opened = pm.open_folder(str(folder)) + assert opened.id == created.id + assert opened.id != project_key(str(folder)) + matching = [p for p in pm.list() if p.path == str(folder.resolve())] + assert len(matching) == 1 + + def test_open_folder_on_default_project_path_keeps_default(self, pm): + default = pm.get_default_project() + opened = pm.open_folder(default.path) + assert opened.id == DEFAULT_PROJECT_ID diff --git a/tests/prompting/test_workspace_files.py b/tests/prompting/test_workspace_files.py index e7ec98ddb..7b7446f72 100644 --- a/tests/prompting/test_workspace_files.py +++ b/tests/prompting/test_workspace_files.py @@ -182,3 +182,89 @@ def test_plain_text_is_all_free_region(): r0, r1, r2 = wf.split_profile_regions('just some intro text\n') assert r0 == '' and r1 == '' assert r2 == 'just some intro text\n' + + +# ── UI write API (TUI / WebUI) ─────────────────────────────────────────────── + + +def test_global_instruction_writes_user_region(home): + wf.write_global_instruction('Always answer in French.') + text = (home / 'AGENTS.md').read_text(encoding='utf-8') + assert text.lstrip().startswith('---') + assert 'Always answer in French.' in text + assert wf.read_global_instruction() == 'Always answer in French.' + block = wf.global_instructions_block() + assert 'Always answer in French.' in block + assert 'legacy:settings.json' not in block + + +def test_global_instruction_migrates_settings_then_clears(home): + from ms_agent.personalization.settings import PersonalizationSettings + from ms_agent.personalization.types import PersonalizationConfig + PersonalizationSettings().save( + PersonalizationConfig(global_instruction='你的名字是小黑')) + assert wf.read_global_instruction() == '你的名字是小黑' + assert PersonalizationSettings().load().global_instruction == '' + assert '你的名字是小黑' in (home / 'AGENTS.md').read_text('utf-8') + + +def test_project_instruction_writes_private_slot_only(home, tmp_path): + work = tmp_path / 'repo' + work.mkdir() + root = work / 'AGENTS.md' + root.write_text('# team file\nDo not touch.\n', encoding='utf-8') + wf.write_project_instruction(str(work), 'This project uses uv.') + private = work / '.ms_agent' / 'AGENTS.md' + assert private.read_text(encoding='utf-8').strip() == 'This project uses uv.' + assert root.read_text(encoding='utf-8') == '# team file\nDo not touch.\n' + assert wf.read_project_instruction(str(work)).strip() == 'This project uses uv.' + block = wf.project_instructions_block(str(work)) + assert 'Do not touch' in block + assert 'This project uses uv.' in block + assert block.index('Do not touch') < block.index('This project uses uv.') + + +def test_project_instruction_migrates_project_json(home, tmp_path): + from ms_agent.project.manager import ProjectManager + work = tmp_path / 'legacy-proj' + work.mkdir() + created = ProjectManager(base_dir=str(home)).create( + name='Legacy', path=str(work), instruction='老项目规矩', + init_workspace=False) + got = wf.read_project_instruction(str(work)) + assert got.strip() == '老项目规矩' + assert (work / '.ms_agent' / 'AGENTS.md').read_text('utf-8').strip() == '老项目规矩' + assert (ProjectManager(base_dir=str(home)).get(created.id).instruction + or '') == '' + assert not (work / 'AGENTS.md').exists() + + +def test_write_project_clears_stale_instruction_field(home, tmp_path): + from ms_agent.project.manager import ProjectManager + work = tmp_path / 'both' + work.mkdir() + pm = ProjectManager(base_dir=str(home)) + created = pm.create( + name='Both', path=str(work), instruction='stale-json', + init_workspace=False) + (work / '.ms_agent').mkdir(parents=True) + (work / '.ms_agent' / 'AGENTS.md').write_text('from-file\n', encoding='utf-8') + wf.write_project_instruction(str(work), '') + assert (pm.get(created.id).instruction or '') == '' + assert wf.read_project_instruction(str(work)).strip() == '' + + +def test_profile_write_call_me_and_about(home): + wf.write_profile(call_me='Alice', description='Mostly agent work.') + call_me, about = wf.read_profile() + assert call_me == 'Alice' + assert about == 'Mostly agent work.' + text = (home / 'PROFILE.md').read_text(encoding='utf-8') + assert '- Call me: Alice' in text + assert 'Mostly agent work.' in text + block = wf.profile_block() + assert 'Alice' in block and 'Mostly agent work.' in block + wf.write_profile(call_me='') + call_me, about = wf.read_profile() + assert call_me == '' + assert about == 'Mostly agent work.' diff --git a/tests/prompting/test_workspace_internals_hint.py b/tests/prompting/test_workspace_internals_hint.py index 6df513449..b58920492 100644 --- a/tests/prompting/test_workspace_internals_hint.py +++ b/tests/prompting/test_workspace_internals_hint.py @@ -120,3 +120,25 @@ def test_it_reaches_the_system_prompt(tmp_path): agent.session_log = SessionLog(workspace / 'sessions/sid', 'session_sid') agent.session_log.set_metadata_field('fork_after_seq', 6) assert 'branched session with an independent plan' in agent._build_system_content() + + +def test_system_prompt_does_not_dump_config_playbook(tmp_path, monkeypatch): + """86119007: the memory/MCP playbook stays out of the always-on system prompt.""" + home = tmp_path / 'ms_home' + home.mkdir() + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + workspace = tmp_path / 'mounted' + workspace.mkdir() + records = tmp_path / 'data' / 'projects' / 'p1' / 'sessions' / 'sid' + records.mkdir(parents=True) + + section = _agent_for( + workspace, session_id='sid', + log_dir=records)._build_workspace_internals_section() + + assert '## Configuring memory and MCP' not in section + assert '/memory on' not in section + assert '/mcp add' not in section + assert 'Do not add memory settings to' not in section + memory_md = str(workspace.resolve() / '.ms_agent' / 'memory' / 'MEMORY.md') + assert memory_md not in section diff --git a/tests/skill/test_skill_discovery.py b/tests/skill/test_skill_discovery.py index 9e0f7477e..e9af2afc6 100644 --- a/tests/skill/test_skill_discovery.py +++ b/tests/skill/test_skill_discovery.py @@ -81,6 +81,28 @@ def _read_text(path, *args, **kwargs): assert reads == [tmp_path / 'alpha' / 'SKILL.md'] +def test_discover_lists_skill_without_description(tmp_path): + skill = tmp_path / 'demo-skill' + skill.mkdir() + (skill / 'SKILL.md').write_text( + '---\nname: demo-skill\n---\n# Demo\n', encoding='utf-8') + found = SkillLoader().discover_skills(str(tmp_path)) + row = next(iter(found.values())) + assert row.name == 'demo-skill' + assert row.description == 'demo-skill' + + +def test_parse_directory_fills_missing_description(tmp_path): + skill = tmp_path / 'demo-skill' + skill.mkdir() + (skill / 'SKILL.md').write_text( + '---\nname: demo-skill\n---\n# Demo\n', encoding='utf-8') + schema = SkillSchemaParser.parse_skill_directory(skill) + assert schema is not None + assert schema.name == 'demo-skill' + assert schema.description == 'demo-skill' + + def test_full_parse_reuses_exact_legacy_files_signature(tmp_path): skill = _make_skill(tmp_path, 'alpha') (skill / 'z.txt').write_text('z', encoding='utf-8') diff --git a/tests/skill/test_skills_manager.py b/tests/skill/test_skills_manager.py index b50e0029b..9d963a1ba 100644 --- a/tests/skill/test_skills_manager.py +++ b/tests/skill/test_skills_manager.py @@ -102,3 +102,43 @@ def test_corrupt_file_is_preserved(self, mgr, tmp_path): with pytest.raises(ValueError, match='valid JSON'): mgr.add_source('new-source') assert path.read_text() == 'not json{{{' + + def test_import_from_path_copies_skill_dir(self, mgr, tmp_path): + src = tmp_path / 'pack' / 'demo-skill' + src.mkdir(parents=True) + (src / 'SKILL.md').write_text('# Demo\n') + names = mgr.import_from_path(str(src), scope='global') + assert names == ['demo-skill'] + dest = mgr.global_skills_tree() / 'demo-skill' / 'SKILL.md' + assert dest.is_file() + assert dest.read_text() == '# Demo\n' + + def test_import_from_path_project_scope(self, mgr, tmp_path): + proj = tmp_path / 'repo' + proj.mkdir() + src = tmp_path / 'local-skill' + src.mkdir() + (src / 'SKILL.md').write_text('# Local\n') + names = mgr.import_from_path( + str(src), scope='project', project_path=str(proj)) + assert names == ['local-skill'] + dest = mgr.project_skills_tree(str(proj)) / 'local-skill' / 'SKILL.md' + assert dest.is_file() + + def test_remove_imported_deletes_live_tree_dir(self, mgr, tmp_path): + src = tmp_path / 'pack' / 'demo-skill' + src.mkdir(parents=True) + (src / 'SKILL.md').write_text('# Demo\n') + mgr.import_from_path(str(src), scope='global') + mgr.set_skill_enabled('demo-skill', False) + dest = mgr.remove_imported('demo-skill', scope='global') + assert not dest.exists() + assert 'demo-skill' not in mgr.load_global().get('disabled', []) + + def test_remove_imported_rejects_non_managed(self, mgr): + with pytest.raises(FileNotFoundError, match='not a managed skill'): + mgr.remove_imported('ghost') + + def test_import_from_path_missing_raises(self, mgr, tmp_path): + with pytest.raises(FileNotFoundError): + mgr.import_from_path(str(tmp_path / 'missing')) diff --git a/tests/skill/test_update_notice.py b/tests/skill/test_update_notice.py index b123c4e81..ee4ffa8aa 100644 --- a/tests/skill/test_update_notice.py +++ b/tests/skill/test_update_notice.py @@ -90,7 +90,8 @@ def test_update_notice_hint_present_when_enabled(self, tmp_path): injector = SkillPromptInjector(cat, update_notice=True) section = injector.build_skill_prompt_section() assert 'skill-update notice' in section - assert 'LATEST one is authoritative' in section + assert 'skills_list' in section + assert 'FULL' not in section def test_hint_absent_by_default(self, tmp_path): cat = _catalog(tmp_path) diff --git a/tests/skills/test_update_config.py b/tests/skills/test_update_config.py new file mode 100644 index 000000000..33b5137f5 --- /dev/null +++ b/tests/skills/test_update_config.py @@ -0,0 +1,48 @@ +"""The bundled update-config skill is not loaded. + +TUI still prepares the skill catalog so user and project skills work. +""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from omegaconf import OmegaConf + +from ms_agent.agent.llm_agent import LLMAgent +from ms_agent.skill.catalog import BUILTIN_SKILLS_DIR, SkillCatalog +from ms_agent.skill.harness import fill_harness_placeholders +from ms_agent.tui.app import TuiApp + + +def test_update_config_is_not_bundled(): + assert not (BUILTIN_SKILLS_DIR / 'update-config' / 'SKILL.md').exists() + catalog = SkillCatalog(config=OmegaConf.create({})) + catalog.load_from_config(OmegaConf.create({})) + assert catalog.get_skill('update-config') is None + + +def test_other_skills_are_not_rewritten(): + body = 'Keep {memory_md} literal in user skills.' + out = fill_harness_placeholders(body, None, skill_id='some-user-skill') + assert out == body + + +def test_tui_defaults_still_prepare_skills(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + cfg = TuiApp._load_runtime_config( + 'unused.yaml', str(work), explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work)) + assert bool(getattr(cfg, 'skills', None)) + assert OmegaConf.select(cfg, 'skills.prompt_injection') == 'all' + + agent = LLMAgent(config=cfg, tag='tui-skills') + agent.tool_manager = MagicMock() + agent.tool_manager.index_extra_tool = AsyncMock() + asyncio.run(agent.prepare_skills()) + assert agent._skill_catalog is not None + assert agent._skill_catalog.get_skill('update-config') is None + assert 'update-config' not in agent._build_system_content() diff --git a/tests/tools/test_mcp_teardown.py b/tests/tools/test_mcp_teardown.py new file mode 100644 index 000000000..2d56e6016 --- /dev/null +++ b/tests/tools/test_mcp_teardown.py @@ -0,0 +1,260 @@ +"""86121430: MCP streamable_http / jupyter teardown must not dump stack on quit.""" +from __future__ import annotations + +import asyncio +import base64 +import inspect +import os +from datetime import timedelta +from types import SimpleNamespace + +import pytest +from mcp.client.session import ClientSession + +from ms_agent.tools.mcp_client import (MCPClient, _is_teardown_noise, + _session_read_timeout, + _tool_call_is_error, _tool_input_schema) + + +def test_session_read_timeout_matches_installed_client(): + value = _session_read_timeout(12) + annotation = str(inspect.signature( + ClientSession.__init__).parameters['read_timeout_seconds'].annotation) + if 'timedelta' in annotation and 'float' not in annotation: + assert value == timedelta(seconds=12) + else: + assert value == 12.0 + + +def test_tool_call_error_reads_mcp2_name_then_mcp1_name(): + assert _tool_call_is_error(SimpleNamespace(is_error=True)) + assert not _tool_call_is_error(SimpleNamespace(is_error=False)) + assert _tool_call_is_error(SimpleNamespace(isError=True)) + assert not _tool_call_is_error(SimpleNamespace(isError=False)) + both = SimpleNamespace(is_error=False, isError=True) + assert not _tool_call_is_error(both) + + +def test_tool_schema_reads_mcp2_name_then_mcp1_name(): + class _V2: + input_schema = {'type': 'object', 'properties': {'msg': {}}} + + class _V1: + inputSchema = {'type': 'object', 'properties': {'q': {}}} + + class _Neither: + pass + + assert _tool_input_schema(_V2())['properties'] == {'msg': {}} + assert _tool_input_schema(_V1())['properties'] == {'q': {}} + both = _V2() + both.inputSchema = {'type': 'object', 'properties': {'old': {}}} + assert 'msg' in _tool_input_schema(both)['properties'] + assert _tool_input_schema(_Neither()) == { + 'type': 'object', + 'properties': {}, + } + + +def test_is_teardown_noise_matches_sdk_errors(): + assert _is_teardown_noise( + RuntimeError( + 'Attempted to exit cancel scope in a different task than it was entered in' + )) + assert _is_teardown_noise( + RuntimeError('athrow(): asynchronous generator is already running')) + assert not _is_teardown_noise(RuntimeError('connection refused')) + + +@pytest.mark.asyncio +async def test_stop_server_does_not_cancel_owner_when_waiter_is_cancelled(): + client = MCPClient({'mcpServers': {}}) + shutdown = asyncio.Event() + cancelled = asyncio.Event() + closed = asyncio.Event() + + async def _owner(): + try: + await shutdown.wait() + await asyncio.sleep(0.05) + except asyncio.CancelledError: + cancelled.set() + raise + finally: + closed.set() + + task = asyncio.create_task(_owner()) + client._server_shutdown['time'] = shutdown + client._server_tasks['time'] = task + await asyncio.sleep(0) + + async def _waiter(): + await client._stop_server('time', graceful=True) + + waiter = asyncio.create_task(_waiter()) + await asyncio.sleep(0) + waiter.cancel() + await waiter + await asyncio.wait_for(closed.wait(), timeout=1) + assert not cancelled.is_set() + assert task.done() + assert not task.cancelled() + + +@pytest.mark.asyncio +async def test_stop_server_swallows_cancel_scope_runtimeerror(): + client = MCPClient({'mcpServers': {}}) + shutdown = asyncio.Event() + + async def _owner(): + await shutdown.wait() + raise RuntimeError( + 'Attempted to exit cancel scope in a different task than it was entered in' + ) + + client._server_shutdown['time'] = shutdown + client._server_tasks['time'] = asyncio.create_task(_owner()) + await asyncio.sleep(0) + await client._stop_server('time', graceful=True) + + +@pytest.mark.asyncio +async def test_cleanup_tools_swallows_mcp_runtimeerror(): + from ms_agent.agent.llm_agent import LLMAgent + + agent = LLMAgent.__new__(LLMAgent) + agent._tools_cleaned = False + agent.task_manager = None + agent.tool_manager = None + agent.memory_tools = [] + + class _Boom: + async def stop(self): + raise RuntimeError( + 'Attempted to exit cancel scope in a different task than it was entered in' + ) + + agent.mcp_runtime = _Boom() + await agent.cleanup_tools() + await agent.cleanup_tools() # idempotent + + +@pytest.mark.asyncio +async def test_quit_during_connect_does_not_leave_a_cancelled_future(): + import gc + + loop = asyncio.get_running_loop() + logged: list[str] = [] + previous = loop.get_exception_handler() + + def _handler(_loop, context): + logged.append(str(context.get('message', ''))) + + loop.set_exception_handler(_handler) + client = MCPClient({'mcpServers': {}}) + + async def _hang(stack, server_name, **kwargs): + await asyncio.Event().wait() + + client._open_session = _hang # type: ignore[method-assign] + connect = asyncio.create_task( + client.connect_to_server('slow', startup_timeout=5)) + await asyncio.sleep(0.02) + connect.cancel() + with pytest.raises(asyncio.CancelledError): + await connect + try: + await client.cleanup() + gc.collect() + await asyncio.sleep(0) + assert all(task.done() for task in client._live_tasks) + assert not any('never retrieved' in message for message in logged) + finally: + loop.set_exception_handler(previous) + await client.cleanup() + + +@pytest.mark.asyncio +async def test_kernel_session_stop_swallows_cancellederror(): + from ms_agent.tools.code.local_code_executor import LocalKernelSession + + session = LocalKernelSession.__new__(LocalKernelSession) + session._client = SimpleNamespace(stop_channels=lambda: None) + + async def _boom(): + raise asyncio.CancelledError() + + session._km = SimpleNamespace(shutdown_kernel=lambda now=True: _boom()) + session.start_ts = 1.0 + session.execution_count = 3 + await session.stop() + assert session._km is None + assert session._client is None + + +class _FakeSession: + def __init__(self, response): + self.response = response + + async def call_tool(self, tool_name, tool_args): + return self.response + + +@pytest.mark.asyncio +async def test_image_audio_and_structured_results_reach_the_caller(tmp_path): + client = MCPClient() + client.output_dir = str(tmp_path) + png = base64.b64encode(b'\x89PNG').decode() + wav = base64.b64encode(b'RIFF').decode() + client.sessions['s'] = _FakeSession( + SimpleNamespace( + is_error=False, + content=[ + SimpleNamespace(type='image', data=png, mime_type='image/png'), + SimpleNamespace(type='audio', data=wav, mimeType='audio/wav'), + ], + structured_content=None, + )) + result = await client.call_tool('s', 'shot', {}) + assert result['attachments'][0]['type'] == 'image' + assert result['attachments'][0]['media_type'] == 'image/png' + assert os.path.isfile(os.path.join(tmp_path, result['attachments'][0]['path'])) + assert 'Audio returned' in result['text'] + assert 'saved at' in result['text'] + + client.sessions['s'] = _FakeSession( + SimpleNamespace( + isError=False, + content=[], + structuredContent={'ok': True}, + )) + text = await client.call_tool('s', 'ping', {}) + assert '"ok": true' in text + + +@pytest.mark.asyncio +async def test_resource_without_model_dump_json_still_returns(): + client = MCPClient() + client.sessions['s'] = _FakeSession( + SimpleNamespace( + is_error=False, + content=[SimpleNamespace(type='resource', resource={'uri': 'file://a'})], + structured_content=None, + )) + result = await client.call_tool('s', 'read', {}) + assert result['resources'] == [{'uri': 'file://a'}] + + +def test_output_schema_is_appended_to_the_tool_description(): + listed = SimpleNamespace( + tools=[ + SimpleNamespace( + name='ping', + description='Ping.', + input_schema={'type': 'object'}, + output_schema={'type': 'object', 'properties': {'msg': {}}}, + ) + ]) + tools = MCPClient()._filter_session_tools('mini', listed) + assert 'Returns:' in tools[0]['description'] + assert 'msg' in tools[0]['description'] diff --git a/tests/tools/test_shell_environment.py b/tests/tools/test_shell_environment.py index ec4528e3a..fd7edfc42 100644 --- a/tests/tools/test_shell_environment.py +++ b/tests/tools/test_shell_environment.py @@ -43,6 +43,35 @@ async def run(): asyncio.run(run()) +def test_shell_call_ignores_unknown_description(tmp_path, monkeypatch): + monkeypatch.setattr(LocalCodeExecutionTool, '_check_dependencies', + lambda self: {}) + monkeypatch.setattr( + LocalKernelSession, 'start', + lambda self: pytest.fail('shell-only must not start a kernel')) + monkeypatch.setattr(LocalKernelSession, 'stop', lambda self: None) + + async def run(): + tool = LocalCodeExecutionTool( + config(tmp_path, include=['shell_executor'])) + await tool.connect() + try: + raw = await tool.call_tool( + 'code_executor', + tool_name='shell_executor', + tool_args={ + 'command': 'echo shell-ready', + 'description': 'list the news', + }) + finally: + await tool.cleanup() + assert 'unexpected keyword' not in raw + payload = json.loads(raw) + assert payload['success'] and 'shell-ready' in payload['output'] + + asyncio.run(run()) + + def test_notebook_opt_in_retains_kernel_lifecycle(tmp_path, monkeypatch): events = [] monkeypatch.setattr(LocalCodeExecutionTool, '_check_dependencies', lambda self: {}) diff --git a/tests/tools/test_todolist_lock_dir.py b/tests/tools/test_todolist_lock_dir.py index 3ef8728fe..f4a459288 100644 --- a/tests/tools/test_todolist_lock_dir.py +++ b/tests/tools/test_todolist_lock_dir.py @@ -51,6 +51,17 @@ def test_explicit_lock_subdir_still_wins(tmp_path): assert tool._lock_dir() == os.path.join(str(tmp_path), '.mylocks') +def test_absolute_plan_filename_is_not_joined_under_output_dir(tmp_path): + sess = tmp_path / 'sessions' / 'abc' + sess.mkdir(parents=True) + plan_json = str(sess / 'plan.json') + plan_md = str(sess / 'plan.md') + tool = _make(tmp_path, plan_filename=plan_json, plan_md_filename=plan_md) + paths = tool._paths() + assert paths.plan_json == plan_json + assert paths.plan_md == plan_md + + @pytest.mark.asyncio @pytest.mark.parametrize('auto_render', [True, False]) async def test_session_plan_locations_match_tool_contract(tmp_path, auto_render): diff --git a/tests/tools/test_tool_name_and_reread.py b/tests/tools/test_tool_name_and_reread.py index 279ca24d7..6a2994e60 100644 --- a/tests/tools/test_tool_name_and_reread.py +++ b/tests/tools/test_tool_name_and_reread.py @@ -36,6 +36,33 @@ def test_ambiguous_shorthand_is_not_guessed(): assert resolved is None +def test_unique_suffix_resolves_with_the_same_note(): + manager = _manager_with( + ['skills---skills_list', 'code_executor---shell_executor']) + resolved, note = manager._resolve_tool_name('skills_list') + assert resolved == 'skills---skills_list' + assert 'resolved to' in note + assert 'skills---skills_list' in note + + +def test_tool_segment_containing_separator_is_the_suffix(): + manager = _manager_with(['srv---alpha---beta']) + resolved, note = manager._resolve_tool_name('alpha---beta') + assert resolved == 'srv---alpha---beta' + assert 'resolved to' in note + assert manager._resolve_tool_name('beta')[0] is None + + +def test_shared_suffix_is_not_guessed(): + manager = _manager_with(['a---skills_list', 'b---skills_list']) + assert manager._resolve_tool_name('skills_list')[0] is None + + +def test_fuzzy_neighbour_is_not_renamed(): + manager = _manager_with(['code_executor---shell_executor']) + assert manager._resolve_tool_name('shell_command')[0] is None + + def test_unknown_name_reports_candidates_not_a_traceback(): manager = _manager_with( ['file_system---read_file', 'file_system---write_file']) diff --git a/tests/tui/test_live_config.py b/tests/tui/test_live_config.py new file mode 100644 index 000000000..8dd9f0a6e --- /dev/null +++ b/tests/tui/test_live_config.py @@ -0,0 +1,415 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Next-message config sync stays on the same agent.""" +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from omegaconf import OmegaConf + +from ms_agent.tui.live_config import apply_live_config + + +@pytest.mark.asyncio +async def test_model_change_rebuilds_the_client_in_place(): + config = OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'gpt-4o', + }, + 'generation_config': { + 'stream': True, + }, + }) + live = SimpleNamespace(model='old', config=config, _setup_stub=False) + agent = SimpleNamespace( + config=config, + llm=live, + runtime=SimpleNamespace(llm=live), + memory_tools=[], + _skill_runtime=None, + mcp_runtime=None, + _llm_fp='stale', + _mcp_fp=None, + _memory_guidance='', + ) + + class Rebuilt: + def __init__(self): + self.model = 'gpt-4o' + self.config = config + + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + await apply_live_config(agent, None) + assert agent.llm.model == 'gpt-4o' + assert agent.runtime.llm.model == 'gpt-4o' + assert agent._llm_fp != 'stale' + + +@pytest.mark.asyncio +async def test_unchanged_token_does_not_rebuild(): + from ms_agent.llm.runtime_token import llm_runtime_token + config = OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'gpt-4o', + }, + }) + live = SimpleNamespace(model='gpt-4o', config=config, _setup_stub=False) + agent = SimpleNamespace( + config=config, + llm=live, + runtime=SimpleNamespace(llm=live), + memory_tools=[], + _skill_runtime=None, + mcp_runtime=None, + _llm_fp=llm_runtime_token(config), + _mcp_fp=None, + ) + with patch('ms_agent.llm.LLM.from_config') as rebuild: + await apply_live_config(agent, None) + rebuild.assert_not_called() + + +@pytest.mark.asyncio +async def test_startup_stub_is_left_alone(): + config = OmegaConf.create({ + 'llm': { + 'service': 'modelscope', + 'model': 'Qwen/Qwen3.8-Flash-Next', + }, + }) + live = SimpleNamespace(config=config, model='Qwen/Qwen3.8-Flash-Next', + _setup_stub=True) + agent = SimpleNamespace( + config=config, + llm=live, + runtime=SimpleNamespace(llm=live), + memory_tools=[], + _skill_runtime=None, + mcp_runtime=None, + _llm_fp=None, + ) + with patch('ms_agent.llm.LLM.from_config') as rebuild: + await apply_live_config(agent, None) + rebuild.assert_not_called() + assert agent.llm is live + + +@pytest.mark.asyncio +async def test_cleared_key_drops_the_live_client_and_a_new_key_rebuilds( + tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path)) + config = OmegaConf.create({ + 'llm': { + 'service': 'modelscope', + 'model': 'Qwen/Qwen3.8-Flash-Next', + 'modelscope_api_key': 'sk-SENTINEL-OLD', + 'api_key': 'sk-SENTINEL-OLD', + }, + }) + live = SimpleNamespace( + config=config, + model='Qwen/Qwen3.8-Flash-Next', + client=SimpleNamespace(api_key='sk-SENTINEL-OLD'), + _setup_stub=False, + ) + memory = SimpleNamespace(llm=live, set_llm=lambda llm: setattr( + memory, 'seen', llm)) + agent = SimpleNamespace( + config=config, + llm=live, + runtime=SimpleNamespace(llm=live), + memory_tools=[memory], + _skill_runtime=None, + mcp_runtime=None, + _llm_fp='stale', + ) + + class _KeyError(Exception): + pass + + _KeyError.__name__ = 'OpenAIError' + + class Rebuilt: + def __init__(self): + self.model = 'Qwen/Qwen3.8-Flash-Next' + self.config = config + self.client = SimpleNamespace(api_key='sk-SENTINEL-NEW') + self._setup_stub = False + + with patch('ms_agent.llm.LLM.from_config', + side_effect=_KeyError( + 'The api_key client option must be set')): + await apply_live_config(agent, None) + assert agent.llm is not live + assert agent.llm._setup_stub is True + assert agent.runtime.llm is agent.llm + assert agent.llm.config is config + assert not hasattr(agent.llm, 'client') + assert memory.seen is None + + config.llm.modelscope_api_key = 'sk-SENTINEL-NEW' + config.llm.api_key = 'sk-SENTINEL-NEW' + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + await apply_live_config(agent, None) + assert agent.llm.client.api_key == 'sk-SENTINEL-NEW' + assert agent.runtime.llm.client.api_key == 'sk-SENTINEL-NEW' + assert memory.seen is agent.llm + + +@pytest.mark.asyncio +async def test_unrelated_rebuild_error_keeps_the_live_client( + tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path)) + config = OmegaConf.create({ + 'llm': { + 'service': 'modelscope', + 'model': 'Qwen/Qwen3.8-Flash-Next', + 'modelscope_api_key': 'sk-SENTINEL-OLD', + }, + }) + live = SimpleNamespace( + config=config, + client=SimpleNamespace(api_key='sk-SENTINEL-OLD'), + _setup_stub=False, + ) + agent = SimpleNamespace( + config=config, + llm=live, + runtime=SimpleNamespace(llm=live), + memory_tools=[], + _skill_runtime=None, + mcp_runtime=None, + _llm_fp='stale', + ) + with patch('ms_agent.llm.LLM.from_config', + side_effect=RuntimeError('catalog down')): + await apply_live_config(agent, None) + assert agent.llm is live + assert agent.llm.client.api_key == 'sk-SENTINEL-OLD' + + +def test_missing_key_error_is_the_setup_sentence(): + from ms_agent.llm.credentials import ( + client_rejected_missing_key, is_missing_api_key_error, + missing_api_key_error) + config = OmegaConf.create({'llm': {'service': 'modelscope'}}) + err = missing_api_key_error(config) + assert is_missing_api_key_error(err) + assert 'modelscope' in str(err) + + class _KeyError(Exception): + pass + + _KeyError.__name__ = 'OpenAIError' + assert client_rejected_missing_key( + _KeyError('The api_key client option must be set')) + assert not client_rejected_missing_key(RuntimeError('api_key')) + assert not client_rejected_missing_key(_KeyError('connection reset')) + + +def _quiet_agent(tmp_path, **extra): + config = OmegaConf.create({'output_dir': str(tmp_path)}) + agent = SimpleNamespace( + config=config, + llm=None, + runtime=SimpleNamespace(), + memory_tools=[], + _skill_runtime=None, + mcp_runtime=None, + tool_manager=None, + _llm_fp=None, + _mcp_fp=None, + _search_fp=None, + _live_notice_keys=set(), + notices=[], + ) + agent._emit_user_notice = agent.notices.append + for key, value in extra.items(): + setattr(agent, key, value) + return agent + + +@pytest.mark.asyncio +async def test_bad_json_is_said_once(tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path)) + (tmp_path / 'mcp.json').write_text('{', encoding='utf-8') + agent = _quiet_agent(tmp_path) + await apply_live_config(agent, None) + await apply_live_config(agent, None) + assert agent.notices == ['mcp.json 读不进去。请检查格式。'] + + +@pytest.mark.asyncio +async def test_pending_mcp_reconnects_when_the_file_is_unchanged( + tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path)) + from ms_agent.tui.live_config import _token + + class Runtime: + def __init__(self): + self.calls = 0 + + async def apply_config(self, config): + del config + self.calls += 1 + + def get_server(self, name): + del name + return SimpleNamespace(status='connected') + + runtime = Runtime() + agent = _quiet_agent( + tmp_path, + mcp_runtime=runtime, + _mcp_fp=_token({}), + _mcp_reconnect_names={'fetch'}, + ) + await apply_live_config(agent, None) + await apply_live_config(agent, None) + assert runtime.calls == 1 + assert agent.notices == [] + assert agent._mcp_reconnect_names == set() + + +@pytest.mark.asyncio +async def test_failed_mcp_reconnect_is_said_once(tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path)) + from ms_agent.tui.live_config import _token + + class Runtime: + def __init__(self): + self.calls = 0 + + async def apply_config(self, config): + del config + self.calls += 1 + + def get_server(self, name): + del name + return SimpleNamespace(status='error') + + runtime = Runtime() + agent = _quiet_agent( + tmp_path, + mcp_runtime=runtime, + _mcp_fp=_token({}), + _mcp_reconnect_names={'fetch'}, + ) + await apply_live_config(agent, None) + await apply_live_config(agent, None) + assert runtime.calls == 1 + assert agent.notices == ['MCP 服务器 fetch 还是连不上。'] + + +@pytest.mark.asyncio +async def test_unreachable_server_is_skipped_before_the_turn( + tmp_path, monkeypatch): + import asyncio + import json + + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path)) + monkeypatch.setattr('ms_agent.mcp.runtime.MCP_PROBE_TIMEOUT', 0.05) + (tmp_path / 'mcp.json').write_text( + json.dumps({ + 'mcpServers': { + 'slow': {'command': 'sleep', 'args': ['30']}, + 'good': {'command': 'echo', 'args': ['hi']}, + }, + }), + encoding='utf-8', + ) + from ms_agent.config.mcp_schema import ResolvedMCPConfig + from ms_agent.mcp.runtime import MCPRuntime + + class Hung: + def __init__(self): + self.sessions = {} + self.connect_calls: list[str] = [] + + def is_connected(self, name): + return name in self.sessions + + async def connect_single_server(self, server_name, server_config, **kwargs): + self.connect_calls.append(server_name) + if server_name == 'slow': + await asyncio.Event().wait() + self.sessions[server_name] = object() + return server_name + + async def disconnect_server(self, server_name, **kwargs): + self.sessions.pop(server_name, None) + + async def get_tools_for_server(self, server_name): + return [] + + client = Hung() + runtime = MCPRuntime( + mcp_client=client, # type: ignore[arg-type] + config=ResolvedMCPConfig(mcp_servers={}), + owns_client=False, + ) + agent = _quiet_agent(tmp_path, mcp_runtime=runtime) + started = asyncio.get_running_loop().time() + await apply_live_config(agent, None) + elapsed = asyncio.get_running_loop().time() - started + assert elapsed < 0.5 + assert runtime.get_server('slow').status == 'error' + assert runtime.get_server('good').status == 'connected' + assert agent.notices == [ + 'MCP 服务器 slow 连不上(0.05秒内没有连上),本轮先不用。' + ] + await apply_live_config(agent, None) + assert client.connect_calls.count('slow') == 1 + assert agent.notices == [ + 'MCP 服务器 slow 连不上(0.05秒内没有连上),本轮先不用。' + ] + + +@pytest.mark.asyncio +async def test_memory_toggle_applies_on_the_next_message(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + from ms_agent.project import ProjectManager + ProjectManager(base_dir=str(home)).open_folder( + str(work), memory_enabled=True, memory_backend='file') + agent = _quiet_agent(work) + agent.runtime._memory_pending = True + + async def load_memory(): + agent.memory_tools.append(object()) + + agent.load_memory = load_memory + await apply_live_config(agent, None) + assert agent.config.memory.unified_memory.storage.backend == 'file' + assert agent.memory_tools + assert agent.runtime._memory_pending is False + + +@pytest.mark.asyncio +async def test_search_engine_reloads_on_the_next_message(tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path)) + from ms_agent.config.search_settings import SearchSettingsManager + + SearchSettingsManager(tmp_path).set_engine('arxiv') + + class Tool: + SERVER_NAME = 'web_search' + _engine_types = ['tavily'] + _api_keys = {} + _engines = {'tavily': object()} + + def __init__(self): + self.reloaded = False + + async def reload_from_config(self): + self.reloaded = True + self._engine_types = ['arxiv'] + + tool = Tool() + agent = _quiet_agent(tmp_path) + agent.tool_manager = SimpleNamespace(extra_tools=[tool]) + await apply_live_config(agent, None) + assert tool.reloaded is True diff --git a/tests/tui/test_live_terminal.py b/tests/tui/test_live_terminal.py new file mode 100644 index 000000000..da4fb9e03 --- /dev/null +++ b/tests/tui/test_live_terminal.py @@ -0,0 +1,138 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""The streaming region and the prompt share a real terminal. + +A pipe cannot show this: Live has to redraw in place, and paste / arrow keys +are only keys once prompt_toolkit owns the tty by itself. +""" +import os +import pty +import select +import struct +import subprocess +import sys +import termios +import time + +import fcntl +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +CHILD = r''' +import asyncio +import sys + +from rich.console import Console + +from ms_agent.tui.input import PromptToolkitInput +from ms_agent.tui.renderer import RichEventSink +from ms_agent.tui.state import TuiState +from ms_agent.ui.events import ContentDelta, ContentEnd + + +async def main(): + console = Console(force_terminal=True, width=40, height=12) + sink = RichEventSink(console, TuiState()) + body = "\n".join("line %02d unique-token" % i for i in range(30)) + sink.emit(ContentDelta(body)) + await asyncio.sleep(0.45) + sink.emit(ContentEnd()) + inp = PromptToolkitInput(TuiState(), console=console, renderer=sink) + if inp._session is None: + print("NO_SESSION", file=sys.stderr, flush=True) + return + inp._session.history.append_string("previous-command") + recalled = await inp.read_prompt() + pasted = await inp.read_prompt() + queued = await inp.read_prompt() + print("RESULTS %r %r %r" % (recalled, pasted, queued), file=sys.stderr, flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) +''' + + +def _read_pty(master, proc, buf, timeout): + """Pull pty bytes and stderr until timeout. Returns (buf, stderr_lines, why).""" + err_lines = [] + end = time.time() + timeout + while time.time() < end: + watch = [master] + if proc.stderr is not None: + watch.append(proc.stderr) + ready, _, _ = select.select(watch, [], [], 0.1) + if master in ready: + try: + buf += os.read(master, 65536) + except OSError: + return buf, err_lines, 'closed' + if proc.stderr is not None and proc.stderr in ready: + line = proc.stderr.readline() + if line: + err_lines.append(line) + if line.startswith(b'RESULTS') or line.startswith(b'NO_SESSION'): + return buf, err_lines, 'done' + if proc.poll() is not None and not ready: + return buf, err_lines, 'exit' + return buf, err_lines, 'timeout' + + +@pytest.mark.skipif(sys.platform == 'win32', reason='needs a posix pty') +def test_one_screen_live_then_paste_and_history(tmp_path): + script = tmp_path / 'drive.py' + script.write_text(CHILD) + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 12, 40, 0, 0)) + env = os.environ.copy() + env['PYTHONPATH'] = REPO + env['PYTHONUNBUFFERED'] = '1' + env['TERM'] = 'xterm-256color' + proc = subprocess.Popen( + [sys.executable, str(script)], + stdin=slave, + stdout=slave, + stderr=subprocess.PIPE, + env=env, + cwd=REPO, + ) + os.close(slave) + buf = b'' + err_lines = [] + try: + prompt = '❯'.encode() + deadline = time.time() + 8 + while time.time() < deadline and prompt not in buf: + buf, extra, why = _read_pty(master, proc, buf, 0.3) + err_lines.extend(extra) + if why in ('done', 'closed'): + break + assert prompt in buf, buf[-400:] + os.write(master, b'\x1b[A\r') + + deadline = time.time() + 5 + while time.time() < deadline and buf.count(prompt) < 2: + buf, extra, why = _read_pty(master, proc, buf, 0.3) + err_lines.extend(extra) + if why in ('done', 'closed'): + break + assert buf.count(prompt) >= 2, buf[-400:] + os.write(master, b'\x1b[200~/model qwen\nhello\x1b[201~\r') + + buf, extra, why = _read_pty(master, proc, buf, 4) + err_lines.extend(extra) + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=3) + os.close(master) + + text = buf.decode('utf-8', 'replace') + err = b''.join(err_lines).decode() + # The first line of a tall reply is typeset once. The refresh loop only + # rewrites the last screen, in place. + assert text.count('line 00') == 1 + assert text.count('Assistant') == 1 + assert b'\x1b[1A' in buf + assert '^[[200~' not in text and '^[[201~' not in text + assert "RESULTS 'previous-command' '/model qwen' 'hello'" in err diff --git a/tests/tui/test_renderer.py b/tests/tui/test_renderer.py index 80f6a3bd7..2e4871eb1 100644 --- a/tests/tui/test_renderer.py +++ b/tests/tui/test_renderer.py @@ -2,7 +2,9 @@ """RichEventSink: renders the AgentEvent stream and tracks status state.""" from io import StringIO +import rich.live from rich.console import Console +from rich.markdown import Markdown from ms_agent.tui.renderer import RichEventSink from ms_agent.tui.state import TuiState @@ -28,7 +30,7 @@ def test_content_stream_renders_text(): sink.emit(ContentDelta('Hello ')) sink.emit(ContentDelta('world')) sink.emit(ContentEnd()) - assert 'Hello world' in _out(console) + assert _out(console).count('Hello world') == 1 def test_tool_call_and_result_render(): @@ -158,3 +160,132 @@ def test_nested_holds_flush_once_at_the_outermost_release(): sink.emit(Notice(level='success', text='inner')) assert 'inner' not in _out(console) # inner exit must NOT flush assert 'inner' in _out(console) + + +def _rendered(renderable, width: int) -> str: + buf = StringIO() + Console(file=buf, force_terminal=False, width=width, height=50).print( + renderable) + return buf.getvalue() + + +def test_unavailable_label_is_bold_and_the_rest_is_plain(): + sink, console, _ = _sink() + captured = {} + original = console.print + + def _spy(*args, **kwargs): + captured['args'] = args + return original(*args, **kwargs) + + console.print = _spy + sink.emit( + Notice( + level='info', + text=('API key: not required\n' + 'UNAVAILABLE: Package `arxiv` is not installed.'))) + body = captured['args'][0].renderable + assert body.plain.startswith('API key: not required') + assert '[bold]' not in body.plain + bold = [ + span for span in body.spans + if getattr(span.style, 'bold', None) or span.style == 'bold' + ] + assert len(bold) == 1 + assert body.plain[bold[0].start:bold[0].end] == 'UNAVAILABLE' + + +def test_stream_window_is_only_the_last_screen(): + console = Console(file=StringIO(), force_terminal=True, width=10, height=5) + sink = RichEventSink(console, TuiState()) + window = sink._stream_window('HEAD' + ('x' * 200) + 'TAIL') + body = _rendered(window, 10).rstrip('\n') + assert 'TAIL' in body + assert 'HEAD' not in body + assert len(body.split('\n')) <= 4 # height - 1 + + +def test_live_refreshes_one_screen_then_typesets_once(): + """A tall reply must not be the Live renderable, and the title is printed + once, after the refresh thread has been joined.""" + events = [] + + class _Thread: + def join(self, timeout=None): + events.append('join') + + class _Live: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + self.updates = [] + self._refresh_thread = _Thread() + events.append(('live', kwargs)) + + def start(self): + events.append('start') + + def update(self, renderable): + self.updates.append(renderable) + events.append(('update', renderable)) + + def stop(self): + events.append('stop') + + console = Console(file=StringIO(), force_terminal=True, width=10, height=5) + sink = RichEventSink(console, TuiState()) + original = console.print + + def _spy(*args, **kwargs): + events.append(('print', args[0] if args else None)) + return original(*args, **kwargs) + + console.print = _spy + real = rich.live.Live + rich.live.Live = _Live + try: + sink.emit(ContentDelta('HEAD' + ('y' * 200) + 'TAIL')) + sink.emit(ContentEnd()) + finally: + rich.live.Live = real + + live = next(item[1] for item in events if item[0] == 'live') + assert live['vertical_overflow'] == 'crop' + assert live['transient'] is True + assert live['redirect_stdout'] is False + assert live['redirect_stderr'] is False + updated = next(item[1] for item in events if item[0] == 'update') + shown = _rendered(updated, 10) + assert 'TAIL' in shown and 'HEAD' not in shown + assert events.index('stop') < events.index('join') + markdown_at = next( + i for i, item in enumerate(events) + if item[0] == 'print' and isinstance(item[1], Markdown)) + assert events.index('join') < markdown_at + titles = [ + item[1] for item in events + if item[0] == 'print' and isinstance(item[1], str) and 'Assistant' in item[1] + ] + assert len(titles) == 1 + assert sink._live is None + + +def test_release_joins_refresh_thread_before_returning(): + import sys + console = Console(file=StringIO(), force_terminal=True, width=20, height=8) + sink = RichEventSink(console, TuiState()) + stdout = sys.stdout + try: + sink.emit(ContentDelta('partial reply')) + live = sink._live + thread = live._refresh_thread + assert thread is not None and thread.is_alive() + assert sys.stdout is stdout # Live must not steal stdout + sink.release_terminal() + assert not thread.is_alive() + assert sink._live is None + assert sys.stdout is stdout + finally: + sink.release_terminal() + out = _out(console) + assert out.count('Assistant') == 1 + assert 'partial reply' in out diff --git a/tests/tui/test_tui_config_align.py b/tests/tui/test_tui_config_align.py new file mode 100644 index 000000000..26f2fdfab --- /dev/null +++ b/tests/tui/test_tui_config_align.py @@ -0,0 +1,547 @@ +"""TUI must boot on the packaged yaml and run WebUI's default model. + +Tester gaps this file locks down: + +1. Writing ``tools.todo_list.plan_filename`` without ``mcp: false`` makes + ToolManager treat todo_list as an MCP server (``'url' or 'command' + parameter is required``). WebUI seeds settings.json; a fresh TUI home + does not. +2. ``/model list`` already read settings.json, but the live LLM came from + Config.from_task(agent.yaml). Default TUI now goes through ConfigResolver + so ``default_model`` / ``llm`` is what the first turn actually uses. +""" +from __future__ import annotations + +import asyncio +import io +import json +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from omegaconf import OmegaConf +from rich.console import Console + +from ms_agent.config.config import Config +from ms_agent.config.resolver import ConfigResolver +from ms_agent.project import SessionManager +from ms_agent.tui.app import TUI_RESOLVER_DEFAULTS, TuiApp +from ms_agent.tui.state import TuiState +from ms_agent.tools.mcp_client import MCPClient +from ms_agent.tools.tool_manager import ToolManager + + +def _write_settings(home: Path, payload: dict) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / 'settings.json').write_text( + json.dumps(payload), encoding='utf-8') + + +def test_plan_filename_without_mcp_flag_is_treated_as_mcp(): + """Document the crash the testers hit: update plan path, omit mcp:false.""" + cfg = OmegaConf.create({}) + OmegaConf.update( + cfg, 'tools.todo_list.plan_filename', '/tmp/plan.json', merge=True) + servers = Config.convert_mcp_servers_to_json(cfg)['mcpServers'] + assert 'todo_list' in servers + client = MCPClient(config=cfg) + assert 'todo_list' in client.mcp_config['mcpServers'] + + async def _boom(): + try: + await client.connect() + finally: + await client.cleanup() + + with pytest.raises(ValueError, match='url.*command|command.*url'): + asyncio.run(_boom()) + + +def test_bind_todo_list_session_sets_mcp_false(tmp_path): + cfg = OmegaConf.create({}) + TuiApp._bind_todo_list_session(cfg, str(tmp_path / 'sess')) + assert cfg.tools.todo_list.mcp is False + assert cfg.tools.todo_list.plan_filename.endswith('plan.json') + servers = Config.convert_mcp_servers_to_json(cfg)['mcpServers'] + assert 'todo_list' not in servers + client = MCPClient(config=cfg) + assert 'todo_list' not in client.mcp_config['mcpServers'] + + +def test_fresh_home_default_yaml_does_not_mcp_connect_todo_list( + tmp_path, monkeypatch): + """No WebUI-seeded settings.json: default TUI still must not MCP todo_list.""" + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + + cfg = TuiApp._load_runtime_config('unused.yaml', str(work), + explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work)) + TuiApp._bind_todo_list_session(cfg, str(work / 'sess')) + assert cfg.tools.todo_list.mcp is False + servers = Config.convert_mcp_servers_to_json(cfg)['mcpServers'] + assert 'todo_list' not in servers + + async def _connect(): + # This is the call that raised "'url' or 'command' parameter is + # required" when todo_list lacked mcp:false. + client = MCPClient(config=cfg) + try: + await client.connect() + finally: + await client.cleanup() + OmegaConf.update(cfg, 'llm.api_key', 'sk-test', merge=True) + OmegaConf.update(cfg, 'llm.modelscope_api_key', 'sk-test', merge=True) + manager = ToolManager(cfg) + try: + await manager.connect() + names = { + getattr(t, 'SERVER_NAME', None) + for t in (manager.extra_tools or []) + } + assert 'todo_list' in names + assert 'web_search' in names + finally: + await manager.cleanup() + + asyncio.run(_connect()) + + +def test_fresh_home_enables_web_search(tmp_path, monkeypatch): + """A home with no settings.json still resolves WebUI's search default.""" + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + cfg = TuiApp._load_runtime_config('unused.yaml', str(work), + explicit_config=False) + assert cfg.tools.web_search.enabled is True + assert cfg.tools.web_search.engine == 'tavily' + assert cfg.tools.web_search.mcp is False + assert not (home / 'settings.json').is_file() + + +def test_settings_can_turn_web_search_off(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + _write_settings(home, {'tools': {'web_search': {'enabled': False}}}) + cfg = TuiApp._load_runtime_config('unused.yaml', str(work), + explicit_config=False) + assert cfg.tools.web_search.enabled is False + + +def test_default_tui_disables_snapshots(tmp_path, monkeypatch): + """TUI has no rollback slash; auto-snapshot must not run on first turn.""" + from ms_agent.agent.llm_agent import LLMAgent + + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + + cfg = TuiApp._load_runtime_config('unused.yaml', str(work), + explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work)) + assert LLMAgent.resolve_enable_snapshots(cfg) is False + + +def test_explicit_config_can_reenable_snapshots(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + yaml_path = tmp_path / 'with-snaps.yaml' + yaml_path.write_text( + 'enable_snapshots: true\n' + 'llm:\n service: modelscope\n model: from-yaml\n' + 'tools:\n file_system:\n mcp: false\n', + encoding='utf-8') + + cfg = TuiApp._load_runtime_config( + str(yaml_path), str(work), explicit_config=True) + from ms_agent.agent.llm_agent import LLMAgent + assert LLMAgent.resolve_enable_snapshots(cfg) is True + + +def test_default_tui_uses_webui_default_model(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + _write_settings(home, { + 'default_model': 'openai/qwen3.7-plus', + 'llm': { + 'provider': 'openai', + 'model': 'qwen3.7-plus', + }, + 'providers': { + 'openai': { + 'api_key': 'sk-from-catalog', + 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'protocol': 'openai', + }, + }, + }) + + cfg = TuiApp._load_runtime_config('unused.yaml', str(work), + explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work)) + assert cfg.llm.service == 'openai' + assert cfg.llm.model == 'qwen3.7-plus' + assert cfg.llm.use_provider_router is True + assert cfg.llm.openai_api_key == 'sk-from-catalog' + assert cfg.llm.protocol == 'openai' + + +def test_default_model_only_no_llm_block(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + _write_settings(home, {'default_model': 'openai/qwen3.7-plus'}) + + cfg = TuiApp._load_runtime_config('unused.yaml', str(work), + explicit_config=False) + assert cfg.llm.service == 'openai' + assert cfg.llm.model == 'qwen3.7-plus' + + +def test_project_patch_wins_over_settings_model(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + _write_settings(home, { + 'default_model': 'openai/qwen3.7-plus', + 'llm': {'provider': 'openai', 'model': 'qwen3.7-plus'}, + }) + patch_dir = work / '.ms_agent' + patch_dir.mkdir() + (patch_dir / 'config.yaml').write_text( + 'llm:\n service: modelscope\n model: patched-model\n', + encoding='utf-8') + + cfg = TuiApp._load_runtime_config('unused.yaml', str(work), + explicit_config=False) + assert cfg.llm.model == 'patched-model' + assert cfg.llm.service == 'modelscope' + + +def test_explicit_config_yaml_wins_over_settings(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'work' + work.mkdir() + _write_settings(home, { + 'default_model': 'openai/qwen3.7-plus', + 'llm': {'provider': 'openai', 'model': 'qwen3.7-plus'}, + }) + yaml_path = tmp_path / 'custom.yaml' + yaml_path.write_text( + 'llm:\n service: modelscope\n model: from-yaml\n' + 'tools:\n file_system:\n mcp: false\n', + encoding='utf-8') + + cfg = TuiApp._load_runtime_config( + str(yaml_path), str(work), explicit_config=True) + assert cfg.llm.model == 'from-yaml' + assert cfg.llm.service == 'modelscope' + + +def test_resolver_defaults_keep_mcp_false_when_plan_paths_merge(tmp_path): + """WebUI-shaped overlay: plan filenames without repeating mcp:false.""" + resolver = ConfigResolver( + global_dir=str(tmp_path / 'home'), + defaults=TUI_RESOLVER_DEFAULTS, + ) + cfg = resolver.resolve( + session_overrides={ + 'tools': { + 'todo_list': { + 'plan_filename': '/tmp/s/plan.json', + 'plan_md_filename': '/tmp/s/plan.md', + }, + }, + }) + assert cfg.tools.todo_list.mcp is False + assert 'todo_list' not in Config.convert_mcp_servers_to_json( + cfg)['mcpServers'] + + +def test_apply_session_then_toolmanager_connect(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'repo' + work.mkdir() + app = TuiApp.__new__(TuiApp) + app._project = TuiApp._open_project(str(work)) + app._sm = SessionManager(app._project) + session = app._sm.create(name='chat') + cfg = TuiApp._load_runtime_config('unused.yaml', str(work), + explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work), app._project) + app.agent = SimpleNamespace(config=cfg, load_cache=False) + app.state = TuiState(model='m', perm='auto', work_dir=str(work)) + app._apply_session(session, resume=False) + + async def _connect(): + client = MCPClient(config=app.agent.config) + try: + await client.connect() + assert 'todo_list' not in ( + client.mcp_config.get('mcpServers') or {}) + finally: + await client.cleanup() + OmegaConf.update(app.agent.config, 'llm.api_key', 'sk-test', merge=True) + OmegaConf.update( + app.agent.config, 'llm.modelscope_api_key', 'sk-test', merge=True) + manager = ToolManager(app.agent.config) + try: + await manager.connect() + servers = getattr(manager.servers, 'mcp_config', {}) or {} + assert 'todo_list' not in (servers.get('mcpServers') or {}) + finally: + await manager.cleanup() + + asyncio.run(_connect()) + + +def test_fill_provider_catalog_does_not_clobber_llm_keys(): + cfg = ConfigResolver._settings_to_agent_config({ + 'llm': { + 'provider': 'openai', + 'model': 'qwen3.7-plus', + 'api_key': 'sk-llm-block', + }, + 'providers': { + 'openai': { + 'api_key': 'sk-catalog', + 'base_url': 'https://example.invalid/v1', + 'protocol': 'openai', + }, + }, + }) + assert cfg.llm.openai_api_key == 'sk-llm-block' + assert cfg.llm.openai_base_url == 'https://example.invalid/v1' + assert cfg.llm.protocol == 'openai' + + +def test_missing_api_key_is_classed_as_setup_not_fatal(): + from ms_agent.llm.credentials import ( + is_missing_api_key_error, + missing_api_key_setup_text, + ) + err = ValueError('No API key found for provider "modelscope"') + assert is_missing_api_key_error(err) + assert not is_missing_api_key_error(ValueError('boom')) + assert not is_missing_api_key_error(RuntimeError('No API key found')) + text = missing_api_key_setup_text(err) + assert text == ('还没有 modelscope 的密钥。\n\n' + ' /model provider key modelscope <密钥>') + + +@pytest.mark.asyncio +async def test_serve_keeps_repl_after_turn_api_error(): + """A provider 400 must not print bye and exit the TUI.""" + from unittest.mock import MagicMock + + app = TuiApp.__new__(TuiApp) + buf = io.StringIO() + app.console = Console(file=buf, force_terminal=False, width=100) + app.renderer = MagicMock() + app._queued_query = None + app._pending_switch = None + app._model = 'm' + app.state = TuiState(model='m') + app._owned_session_ids = set() + session = SimpleNamespace(id='s1', name='Session 1') + app._sm = MagicMock() + app._sm.create.return_value = session + app.session = None + app._banner = lambda: None + applied = [] + + def _apply(sess, resume=False): + applied.append(resume) + app.session = sess + + app._apply_session = _apply + app._name_session_from_log = lambda: None + app._prune_if_empty = lambda s: None + + runs = {'n': 0} + + async def fake_run(query=None, stream=True): + runs['n'] += 1 + if runs['n'] == 1: + raise RuntimeError( + "Error code: 400 - {'error': {'message': " + "'The product is not activated'}}") + raise EOFError() + + app.agent = SimpleNamespace( + run=fake_run, + config=OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'm', + }, + }), + ) + + await app._serve() + out = buf.getvalue() + assert runs['n'] == 2 + assert True in applied # failed turn resumed, not a fresh session + assert 'session still open' in out + assert '/quit to exit' in out + assert 'The product is not activated' not in out # no duplicate crash panel + assert out.strip().endswith('bye') or 'bye' in out + + +@pytest.mark.asyncio +async def test_serve_stays_open_on_cancel_scope_error(): + """A leaked MCP cancel-scope error must not quit the TUI.""" + from unittest.mock import MagicMock + + app = TuiApp.__new__(TuiApp) + buf = io.StringIO() + app.console = Console(file=buf, force_terminal=False, width=100) + app.renderer = MagicMock() + app._queued_query = None + app._pending_switch = None + app._model = 'm' + app.state = TuiState(model='m') + app._owned_session_ids = set() + session = SimpleNamespace(id='s1', name='Session 1') + app._sm = MagicMock() + app._sm.create.return_value = session + app.session = None + app._banner = lambda: None + app._apply_session = lambda sess, resume=False: setattr(app, 'session', sess) + app._name_session_from_log = lambda: None + app._prune_if_empty = lambda s: None + + runs = {'n': 0} + + async def fake_run(query=None, stream=True): + runs['n'] += 1 + if runs['n'] == 1: + raise RuntimeError( + 'Attempted to exit cancel scope in a different task') + raise EOFError() + + app.agent = SimpleNamespace( + run=fake_run, + config=OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'm', + }, + }), + runtime=SimpleNamespace(should_stop=False), + ) + + await app._serve() + out = buf.getvalue() + assert runs['n'] == 2 + assert 'session still open' in out + assert 'bye' in out + + +@pytest.mark.asyncio +async def test_serve_stays_open_on_cancelled_error_that_is_not_quit(): + from unittest.mock import MagicMock + + app = TuiApp.__new__(TuiApp) + buf = io.StringIO() + app.console = Console(file=buf, force_terminal=False, width=100) + app.renderer = MagicMock() + app._queued_query = None + app._pending_switch = None + app._model = 'm' + app.state = TuiState(model='m') + app._owned_session_ids = set() + session = SimpleNamespace(id='s1', name='Session 1') + app._sm = MagicMock() + app._sm.create.return_value = session + app.session = None + app._banner = lambda: None + app._apply_session = lambda sess, resume=False: setattr(app, 'session', sess) + app._name_session_from_log = lambda: None + app._prune_if_empty = lambda s: None + + runs = {'n': 0} + + async def fake_run(query=None, stream=True): + runs['n'] += 1 + if runs['n'] == 1: + raise asyncio.CancelledError() + raise EOFError() + + app.agent = SimpleNamespace( + run=fake_run, + config=OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'm', + }, + }), + runtime=SimpleNamespace(should_stop=False), + ) + + await app._serve() + out = buf.getvalue() + assert runs['n'] == 2 + assert 'session still open' in out + + +@pytest.mark.asyncio +async def test_serve_exits_when_quit_cancels_the_turn(): + from unittest.mock import MagicMock + + app = TuiApp.__new__(TuiApp) + buf = io.StringIO() + app.console = Console(file=buf, force_terminal=False, width=100) + app.renderer = MagicMock() + app._queued_query = None + app._pending_switch = None + app._model = 'm' + app.state = TuiState(model='m') + app._owned_session_ids = set() + session = SimpleNamespace(id='s1', name='Session 1') + app._sm = MagicMock() + app._sm.create.return_value = session + app.session = None + app._banner = lambda: None + app._apply_session = lambda sess, resume=False: setattr(app, 'session', sess) + app._name_session_from_log = lambda: None + app._prune_if_empty = lambda s: None + + runs = {'n': 0} + runtime = SimpleNamespace(should_stop=True) + + async def fake_run(query=None, stream=True): + runs['n'] += 1 + raise asyncio.CancelledError() + + app.agent = SimpleNamespace( + run=fake_run, + config=OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'm', + }, + }), + runtime=runtime, + ) + + await app._serve() + out = buf.getvalue() + assert runs['n'] == 1 + assert 'session still open' not in out + assert 'bye' in out diff --git a/tests/tui/test_tui_input.py b/tests/tui/test_tui_input.py index 589378d68..adbf8419e 100644 --- a/tests/tui/test_tui_input.py +++ b/tests/tui/test_tui_input.py @@ -4,7 +4,8 @@ from unittest.mock import patch from ms_agent.command import CommandRouter, register_builtin_commands -from ms_agent.tui.input import PromptToolkitInput, slash_matches +from ms_agent.tui.input import (PromptToolkitInput, slash_matches, + split_prompt_lines) from ms_agent.tui.state import TuiState from ms_agent.ui.input import InputSource @@ -57,3 +58,63 @@ def test_read_prompt_falls_back_when_not_tty(): stdin.isatty.return_value = False result = asyncio.run(inp.read_prompt('> ')) assert result == 'hello' + + +def test_split_prompt_lines_keeps_each_pasted_line(): + assert split_prompt_lines('/model qwen\nhello') == ['/model qwen', 'hello'] + # A trailing Enter is not an extra blank command. + assert split_prompt_lines('/model qwen\nhello\n') == ['/model qwen', 'hello'] + assert split_prompt_lines('/model a\r\nsecond\r\n') == ['/model a', 'second'] + assert split_prompt_lines('just one') == ['just one'] + assert split_prompt_lines('\x1b[200~/model qwen\nhello\x1b[201~') == [ + '/model qwen', 'hello' + ] + assert split_prompt_lines('^[[200~hello^[[201~') == ['hello'] + + +def test_multiline_paste_is_one_prompt_then_queued_lines(): + """The second line of a paste must come back as its own read, so /model + cannot swallow it into the model id. Live is released before the prompt + draws, and a queued line does not draw another divider.""" + from io import StringIO + + from rich.console import Console + + order = [] + + class _Renderer: + def release_terminal(self): + order.append('release') + + class _Session: + calls = 0 + + async def prompt_async(self, *args, **kwargs): + order.append('prompt') + _Session.calls += 1 + return '/model qwen\nhello\n' + + with patch('ms_agent.tui.input.sys.stdin') as stdin: + stdin.isatty.return_value = False + console = Console(file=StringIO(), force_terminal=False, width=40) + prints = [] + original = console.print + + def _spy(*args, **kwargs): + prints.append(args) + return original(*args, **kwargs) + + console.print = _spy + inp = PromptToolkitInput(TuiState(), console=console, renderer=_Renderer()) + inp._session = _Session() + + with patch('ms_agent.tui.input.sys.stdin') as stdin: + stdin.isatty.return_value = True + first = asyncio.run(inp.read_prompt()) + second = asyncio.run(inp.read_prompt()) + + assert first == '/model qwen' + assert second == 'hello' + assert _Session.calls == 1 + assert order == ['release', 'prompt', 'release'] + assert len(prints) == 1 # divider only for the real prompt diff --git a/tests/tui/test_tui_project.py b/tests/tui/test_tui_project.py new file mode 100644 index 000000000..4797492ef --- /dev/null +++ b/tests/tui/test_tui_project.py @@ -0,0 +1,210 @@ +"""TUI binds a work dir through ProjectManager.open_folder (same as WebUI).""" +import os +from pathlib import Path +from types import SimpleNamespace + +from omegaconf import OmegaConf + +from ms_agent.personalization.settings import PersonalizationSettings +from ms_agent.personalization.types import PersonalizationConfig +from ms_agent.project import SessionManager +from ms_agent.project.manager import ProjectManager +from ms_agent.project.paths import project_key +from ms_agent.tui.app import TuiApp +from ms_agent.tui.state import TuiState + + +def test_tui_open_project_registers_path_key(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'repo' + work.mkdir() + + project = TuiApp._open_project(str(work)) + assert project.id == project_key(str(work)) + assert project.path == str(work.resolve()) + listed = ProjectManager(base_dir=str(home)).get(project.id) + assert listed is not None + assert listed.path == project.path + + +def test_tui_open_project_reuses_webui_create_id(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'from-webui' + work.mkdir() + created = ProjectManager(base_dir=str(home)).create( + name='WebUI project', path=str(work), init_workspace=False) + + project = TuiApp._open_project(str(work)) + assert project.id == created.id + assert project.id != project_key(str(work)) + assert Path(project.path).resolve() == work.resolve() + + +def test_tui_and_webui_share_session_tree(tmp_path, monkeypatch): + """Same folder, one project id: WebUI sessions show up in TUI /sessions.""" + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'shared' + work.mkdir() + created = ProjectManager(base_dir=str(home)).create( + name='WebUI', path=str(work), init_workspace=False) + web_sm = SessionManager(created) + web_sess = web_sm.create(name='from webui') + web_sm.get_session_log(web_sess).append( + {'role': 'user', 'content': 'hello from web'}) + + tui_proj = TuiApp._open_project(str(work)) + tui_sm = SessionManager(tui_proj) + assert tui_proj.id == created.id + assert web_sess.id in {s.id for s in tui_sm.list()} + msgs = tui_sm.get_session_log(web_sess).get_all_messages() + assert any(m.get('content') == 'hello from web' for m in msgs) + + tui_sess = tui_sm.create(name='from tui') + tui_sm.get_session_log(tui_sess).append( + {'role': 'user', 'content': 'hello from tui'}) + listed_again = SessionManager(created).list() + assert tui_sess.id in {s.id for s in listed_again} + + +def test_prepare_config_merges_personalization(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + PersonalizationSettings().save( + PersonalizationConfig(global_instruction='Be brief.')) + cfg = OmegaConf.create({}) + project = SimpleNamespace(instruction='Use FastAPI.') + out = TuiApp._prepare_config(cfg, None, str(tmp_path / 'work'), project) + assert out.personalization.project_instruction == 'Use FastAPI.' + assert out.personalization.global_instruction == 'Be brief.' + + +def test_new_conversation_uses_default_not_the_live_model(tmp_path, monkeypatch): + """A new chat starts on the global default, even if the agent is still + sitting on an older conversation's model.""" + from ms_agent.config.model_settings import ModelSettingsManager + + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'repo' + work.mkdir() + ModelSettingsManager(home).set_default_model('new-model', provider='openai') + app = TuiApp.__new__(TuiApp) + app._project = TuiApp._open_project(str(work)) + app._sm = SessionManager(app._project) + app.agent = SimpleNamespace( + config=OmegaConf.create({ + 'llm': {'service': 'openai', 'model': 'old-model'}, + }), + ) + app.state = TuiState(model='old-model', perm='auto', work_dir=str(work)) + session = app._open_conversation() + assert session.model == 'new-model' + assert session.model_provider == 'openai' + assert ModelSettingsManager(home).get_default_model() == 'openai/new-model' + + +def test_apply_session_restores_model_without_changing_default( + tmp_path, monkeypatch): + from ms_agent.config.model_settings import ModelSettingsManager + + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'repo' + work.mkdir() + ModelSettingsManager(home).set_default_model('new-model', provider='openai') + app = TuiApp.__new__(TuiApp) + app._project = TuiApp._open_project(str(work)) + app._sm = SessionManager(app._project) + session = app._sm.create( + name='older', model='old-model', model_provider='other') + app.agent = SimpleNamespace( + config=OmegaConf.create({ + 'llm': {'service': 'openai', 'model': 'new-model'}, + }), + load_cache=False, + ) + app.state = TuiState(model='new-model', perm='auto', work_dir=str(work)) + app._apply_session(session, resume=True) + assert app.agent.config.llm.model == 'old-model' + assert app.agent.config.llm.service == 'other' + assert app.state.model == 'old-model' + assert ModelSettingsManager(home).get_default_model() == 'openai/new-model' + stored = app._sm.get(session.id) + assert stored.model == 'old-model' + assert stored.model_provider == 'other' + + +def test_resume_without_stored_model_follows_default(tmp_path, monkeypatch): + from ms_agent.config.model_settings import ModelSettingsManager + + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'repo' + work.mkdir() + ModelSettingsManager(home).set_default_model('new-model', provider='openai') + app = TuiApp.__new__(TuiApp) + app._project = TuiApp._open_project(str(work)) + app._sm = SessionManager(app._project) + session = app._sm.create(name='legacy') + app.agent = SimpleNamespace( + config=OmegaConf.create({ + 'llm': {'service': 'openai', 'model': 'previous-chat'}, + }), + load_cache=False, + ) + app.state = TuiState( + model='previous-chat', perm='auto', work_dir=str(work)) + app._apply_session(session, resume=True) + assert app.agent.config.llm.model == 'new-model' + assert app.state.model == 'new-model' + assert app._sm.get(session.id).model is None + + +def test_apply_session_binds_plan_to_session_dir(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'repo' + work.mkdir() + app = TuiApp.__new__(TuiApp) + app._project = TuiApp._open_project(str(work)) + app._sm = SessionManager(app._project) + session = app._sm.create(name='chat') + app.agent = SimpleNamespace( + config=OmegaConf.create({}), + load_cache=False, + ) + app.state = TuiState(model='m', perm='auto', work_dir=str(work)) + app._apply_session(session, resume=False) + sess_dir = str(app._sm.sessions_dir / session.id) + plan_json = os.path.join(sess_dir, 'plan.json') + plan_md = os.path.join(sess_dir, 'plan.md') + assert app.agent.config.tools.todo_list.plan_filename == plan_json + assert app.agent.config.tools.todo_list.plan_md_filename == plan_md + assert app.agent.config.tools.todo_list.mcp is False + assert app.agent.config.session_log.dir == sess_dir + from ms_agent.config.config import Config + servers = Config.convert_mcp_servers_to_json( + app.agent.config)['mcpServers'] + assert 'todo_list' not in servers + + +def test_prune_empty_only_drops_owned_sessions(tmp_path, monkeypatch): + home = tmp_path / 'home' + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'repo' + work.mkdir() + app = TuiApp.__new__(TuiApp) + app._project = TuiApp._open_project(str(work)) + app._sm = SessionManager(app._project) + app._owned_session_ids = set() + web = app._sm.create(name='from webui') + owned = app._sm.create(name='tui leftover') + app._owned_session_ids.add(owned.id) + app._prune_if_empty(web) + app._prune_if_empty(owned) + ids = {s.id for s in app._sm.list()} + assert web.id in ids + assert owned.id not in ids diff --git a/tests/utils/test_filesystem_tool_config.py b/tests/utils/test_filesystem_tool_config.py index 2f68b63b6..dc86ab57b 100644 --- a/tests/utils/test_filesystem_tool_config.py +++ b/tests/utils/test_filesystem_tool_config.py @@ -108,3 +108,23 @@ async def _run(): assert result == 'Error: `path` is required.' asyncio.run(_run()) + + +def test_init_does_not_require_api_key(tmp_path, monkeypatch): + monkeypatch.delenv('MODELSCOPE_API_KEY', raising=False) + monkeypatch.delenv('DASHSCOPE_API_KEY', raising=False) + cfg = OmegaConf.create({ + 'output_dir': str(tmp_path), + 'llm': { + 'service': 'modelscope', + 'model': 'Qwen/Qwen3-235B-A22B-Instruct-2507', + 'use_provider_router': True, + }, + 'tools': { + 'file_system': { + 'mcp': False, + }, + }, + }) + fs = FileSystemTool(cfg) + assert fs.llm is None diff --git a/tests/utils/test_snapshot_smoke.py b/tests/utils/test_snapshot_smoke.py index 7a02ec899..a466caf77 100644 --- a/tests/utils/test_snapshot_smoke.py +++ b/tests/utils/test_snapshot_smoke.py @@ -356,9 +356,32 @@ def test_on_task_begin_auto_snapshots(self): Message(role='user', content='do something useful'), ] - # No explicit take_snapshot call — on_task_begin should do it asyncio.run(agent.on_task_begin(messages)) + snaps = list_snapshots(td) + self.assertEqual(len(snaps), 1) + self.assertIn('do something useful', snaps[0]['message']) + self.assertEqual(snaps[0]['message_count'], len(messages)) + def test_on_task_begin_snapshots_when_enabled(self): + """enable_snapshots: true still takes a snapshot on task begin.""" + import asyncio + from omegaconf import OmegaConf + from ms_agent.agent.llm_agent import LLMAgent + from ms_agent.llm.utils import Message + + with tempfile.TemporaryDirectory() as td: + _write(os.path.join(td, 'work.txt'), 'v1') + cfg = OmegaConf.create({ + 'llm': {'model': 'fake', 'api_key': 'fake', 'model_server': 'openai'}, + 'output_dir': td, + 'enable_snapshots': True, + }) + agent = LLMAgent(cfg, tag='smoke-test') + messages = [ + Message(role='system', content='sys'), + Message(role='user', content='do something useful'), + ] + asyncio.run(agent.on_task_begin(messages)) snaps = list_snapshots(td) self.assertEqual(len(snaps), 1) self.assertIn('do something useful', snaps[0]['message']) diff --git a/webui/backend/app/backends/ms_agent/defaults.py b/webui/backend/app/backends/ms_agent/defaults.py index 59fe5c5cd..93ef6d03e 100644 --- a/webui/backend/app/backends/ms_agent/defaults.py +++ b/webui/backend/app/backends/ms_agent/defaults.py @@ -1,5 +1,7 @@ """Default tool scope for WebUI sessions.""" +from ms_agent.config.search_settings import DEFAULT_WEB_SEARCH_BLOCK + # WebUI persists missing defaults at startup and before reading settings or # starting a turn. The same defaults remain below user settings at runtime. DEFAULT_TOOLS: dict = { @@ -27,7 +29,7 @@ # silently having no engine until someone visits Settings -> Search. The # keyless quota is a small sliding hourly bucket; adding a key lifts it, and # a configured key always takes precedence. - "web_search": {"mcp": False, "engine": "tavily", "enabled": True}, + "web_search": dict(DEFAULT_WEB_SEARCH_BLOCK), } # task_control has no WebUI rendering component yet; strip it from any home that diff --git a/webui/backend/app/schemas/agent_settings.py b/webui/backend/app/schemas/agent_settings.py index aca76c2f2..704681768 100644 --- a/webui/backend/app/schemas/agent_settings.py +++ b/webui/backend/app/schemas/agent_settings.py @@ -18,20 +18,21 @@ class AgentSettings(BaseModel): default_provider_id: str | None = None default_model_id: str | None = None - # Inherited by newly-created projects. - default_memory_enabled: bool = True - default_memory_backend: MemoryBackend = "file" + # Inherited by newly-created projects. None on PUT = leave unchanged + # (a missing bool must not fall back to True and silently turn memory on). + default_memory_enabled: bool | None = None + default_memory_backend: MemoryBackend | None = None # Vector-memory model configuration. All None = follow the conversation # model/provider — explicit values pin fact extraction / embeddings to a # model of the user's choosing, independent of what chat uses. memory_llm_provider_id: str | None = None memory_llm_model: str | None = None - memory_embed_mode: MemoryEmbedMode = "provider" + memory_embed_mode: MemoryEmbedMode | None = None memory_embed_provider_id: str | None = None memory_embed_model: str | None = None memory_recall_top_k: int | None = None # Global auto-attach masters — projects can override per-scope. - global_mcp_auto_attach: bool = True - global_skill_auto_attach: bool = True + global_mcp_auto_attach: bool | None = None + global_skill_auto_attach: bool | None = None diff --git a/webui/backend/uv.lock b/webui/backend/uv.lock index b033efa16..259696f7c 100644 --- a/webui/backend/uv.lock +++ b/webui/backend/uv.lock @@ -1695,9 +1695,9 @@ requires-dist = [ { name = "markdown", marker = "extra == 'webui'", specifier = ">=3.10" }, { name = "matplotlib" }, { name = "matplotlib", marker = "extra == 'all'" }, - { name = "mcp", specifier = "<2" }, - { name = "mcp", marker = "extra == 'all'", specifier = "<2" }, - { name = "mcp", marker = "extra == 'research'", specifier = "<2" }, + { name = "mcp" }, + { name = "mcp", marker = "extra == 'all'" }, + { name = "mcp", marker = "extra == 'research'" }, { name = "mem0ai", marker = "extra == 'all'" }, { name = "mem0ai", marker = "extra == 'all'", specifier = ">=2.0.12" }, { name = "mem0ai", marker = "extra == 'code'" },