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'" },