From 9af9dad122f6950ca6e8c6dfbce73cafba863528 Mon Sep 17 00:00:00 2001 From: suluyan Date: Wed, 16 Sep 2026 17:27:44 +0800 Subject: [PATCH 01/21] feat(tui): share WebUI settings ledger for sessions and slash config TUI now reuses the same project/session tree as WebUI and writes provider keys, search, instructions, MCP/skills, and memory flags into MS_AGENT_HOME instead of only the work-dir yaml patch. Co-authored-by: Cursor --- docs/tui-webui-align-e2e.md | 393 +++++++++++++++++++ ms_agent/cli/tui.py | 2 + ms_agent/command/builtin/__init__.py | 9 + ms_agent/command/builtin/config_cmds.py | 329 ++++++++++++++-- ms_agent/command/builtin/instruction_cmds.py | 207 ++++++++++ ms_agent/command/builtin/memory_cmds.py | 197 ++++++++++ ms_agent/command/builtin/resource_cmds.py | 352 +++++++++++++++++ ms_agent/command/builtin/search_cmds.py | 148 +++++++ ms_agent/config/mcp_manager.py | 6 +- ms_agent/config/model_settings.py | 49 +++ ms_agent/config/resolver.py | 26 ++ ms_agent/config/search_settings.py | 199 ++++++++++ ms_agent/config/skills_manager.py | 92 +++++ ms_agent/permission/memory.py | 8 +- ms_agent/personalization/__init__.py | 2 + ms_agent/personalization/memory_apply.py | 51 +++ ms_agent/project/manager.py | 29 +- ms_agent/prompting/workspace_files.py | 140 +++++++ ms_agent/tools/todolist_tool.py | 12 +- ms_agent/tui/app.py | 215 ++++++++-- tests/command/test_builtin.py | 5 + tests/command/test_instruction_cmds.py | 134 +++++++ tests/command/test_memory_cmds.py | 125 ++++++ tests/command/test_new_cmds.py | 74 +++- tests/command/test_resource_cmds.py | 199 ++++++++++ tests/command/test_search_cmds.py | 100 +++++ tests/config/test_model_settings.py | 36 ++ tests/config/test_search_settings.py | 73 ++++ tests/permission/test_memory.py | 9 + tests/project/test_manager.py | 27 ++ tests/prompting/test_workspace_files.py | 86 ++++ tests/skill/test_skills_manager.py | 40 ++ tests/tools/test_todolist_lock_dir.py | 11 + tests/tui/test_tui_config_align.py | 268 +++++++++++++ tests/tui/test_tui_project.py | 128 ++++++ 35 files changed, 3712 insertions(+), 69 deletions(-) create mode 100644 docs/tui-webui-align-e2e.md create mode 100644 ms_agent/command/builtin/instruction_cmds.py create mode 100644 ms_agent/command/builtin/memory_cmds.py create mode 100644 ms_agent/command/builtin/resource_cmds.py create mode 100644 ms_agent/command/builtin/search_cmds.py create mode 100644 ms_agent/config/search_settings.py create mode 100644 ms_agent/personalization/memory_apply.py create mode 100644 tests/command/test_instruction_cmds.py create mode 100644 tests/command/test_memory_cmds.py create mode 100644 tests/command/test_resource_cmds.py create mode 100644 tests/command/test_search_cmds.py create mode 100644 tests/config/test_search_settings.py create mode 100644 tests/tui/test_tui_config_align.py create mode 100644 tests/tui/test_tui_project.py diff --git a/docs/tui-webui-align-e2e.md b/docs/tui-webui-align-e2e.md new file mode 100644 index 000000000..ea79d4449 --- /dev/null +++ b/docs/tui-webui-align-e2e.md @@ -0,0 +1,393 @@ +# TUI / WebUI 设置账本 — 端到端验收 + +两端必须同一 `MS_AGENT_HOME`、同一工作目录。**不要用开发者自己的 `~/.ms_agent`。** + +代码: + +- SDK:`modelscope-agent-feat-tui-align`(分支 `feat/tui-align-webui`) +- WebUI:GitLab `ms-agent-webui-feat-tui-align`(WebUI 后端请用这份 SDK,例如 `PYTHONPATH` 或 editable install) + +## 共用环境 + +```bash +export MS_AGENT_HOME=/tmp/ms-agent-align-e2e +rm -rf "$MS_AGENT_HOME" +mkdir -p "$MS_AGENT_HOME" /tmp/align-work + +# WebUI:启动进程时带上同一 MS_AGENT_HOME +# TUI(在 SDK 工作树): +ms-agent tui --work-dir /tmp/align-work +``` + +换期前可清空 `$MS_AGENT_HOME`。磁盘是唯一验收源;当前这一轮对话若没跟上,脚本会写「需 `/new`」。 + +对齐标准:**同一账本**(TUI slash 能管 WebUI 设置页写的那些文件),不是 TUI/WebUI 界面完全一致。 + +**不测:** 工作区编辑器、多项目 IDE、卡片回放、外观主题、权限 UX、把 TUI 事件还原成 WebUI 卡片。 + +--- + +## 本期功能清单(提测用) + +每条都要两边看:TUI 命令 → 磁盘 → 刷新 WebUI;以及 WebUI 改完 → TUI 再读。建议按 0 → 5 顺序,0 不过后面的设置对不齐。 + +### A. 项目与会话(第 0 期) + +| # | 功能 | 怎么测 | 通过标准 | +|---|---|---|---| +| A1 | 同一文件夹 = 同一项目 | WebUI 打开 `W`,再 TUI `--work-dir W` | 不是两套 project id | +| A2 | WebUI 会话 TUI 能续 | WebUI 说一句 → TUI `/sessions` `/resume` | 能读到那句 | +| A3 | TUI 会话 WebUI 能看到 | TUI 说一句 → 刷新 WebUI | 同一项目、同一会话树 | +| A4 | TUI 空 home 能启动 | 不先开 WebUI,直接 `ms-agent tui` | 不因 todo_list 当 MCP 崩溃 | +| A5 | TUI 默认模型跟 WebUI | WebUI 设默认模型后新开 TUI(无 `--config`) | 第一轮就用该模型,不必先 `/model` | +| A6 | 显式 `--config` 不被 settings 改掉 | `ms-agent tui --config custom.yaml` | 仍用 yaml 里的模型 | + +### B. 模型(第 0 / 3 期) + +落盘:`$MS_AGENT_HOME/settings.json` 的 `providers` / `default_model` / `llm`。 + +| # | 功能 | TUI | 通过标准 | +|---|---|---|---| +| B1 | 列出供应商 | `/model` `/model list` | 与 WebUI 模型设置同源;key 只显示 set/missing,无明文 | +| B2 | 切换当前模型 | `/model ` 或 `/model /` | banner/当前模型变了;settings 的 default_model 更新;下一轮走新模型 | +| B3 | 新增供应商 | `/model provider add key= url= protocol=` | WebUI 模型页出现该项 | +| B4 | 改 key | `/model provider key \|clear` | json 变了;列表仍不打印明文;当前供应商会尽量立刻生效 | +| B5 | 改 base_url | `/model provider url \|clear` | 同上 | +| B6 | 改协议/名称等 | `/model provider set protocol= name=` | 只改给的字段,不把别的冲掉 | +| B7 | 模型目录 | `/model catalog add\|remove ` | WebUI 该供应商的模型列表同步 | +| B8 | 不能删内置 | `/model provider remove openai`(无覆盖时) | 拒绝;有覆盖时只清覆盖,内置还在 | + +### C. 搜索(第 1 期) + +落盘:`settings.json` → `tools.web_search`。 + +| # | 功能 | TUI | 通过标准 | +|---|---|---|---| +| C1 | 看当前引擎 | `/search` `/search list` | 未配置时默认 tavily;当前项前有 `*` | +| C2 | 切引擎 | `/search engine tavily\|exa\|serpapi\|arxiv` | WebUI 搜索页下拉一致 | +| C3 | 写/清 key | `/search key ` `/search key clear` | 按**当前引擎**写 `{engine}_api_key`;arxiv 无需 key | +| C4 | 切引擎不丢别人的 key | 先给 exa 写 key,再切 tavily | json 里 `exa_api_key` 还在 | +| C5 | 开关 | `/search enable\|disable` | WebUI 搜索启用状态一致 | +| C6 | WebUI → TUI | WebUI 改引擎并保存 | TUI `/search` 已是新引擎 | +| C7 | (有网,非必须)对话走该引擎 | `/new` 后让模型去搜 | 工具用的是所选引擎 | + +### D. 指令与 Profile(第 2 期) + +| # | 功能 | TUI | 落盘 | 通过标准 | +|---|---|---|---|---| +| D1 | 看指令 | `/instruction` 或 `/ins` | — | 能分开显示全局 / 项目 | +| D2 | 写/清全局指令 | `/instruction global \|clear` | `$MS_AGENT_HOME/AGENTS.md` 用户区 | 保留模板头;WebUI 个性化指令同一句 | +| D3 | 写/清项目指令 | `/instruction project \|clear` | `/.ms_agent/AGENTS.md` | **绝不改** 仓库根 `/AGENTS.md` | +| D4 | 看 Profile | `/profile` | `$MS_AGENT_HOME/PROFILE.md` | Call me / About | +| D5 | 称呼 | `/profile callme \|clear` | 同上 | 清称呼不抹掉 About | +| D6 | 自我介绍 | `/profile about \|clear` | 同上 | 清 About 不抹掉称呼 | +| D7 | 下一轮生效 | 改完再发一轮 | — | 不必 `/new`;本轮已发出的 prompt 不回写 | + +### E. MCP(第 0 / 4 期) + +落盘:`$MS_AGENT_HOME/mcp.json`(及 `settings.json` 的 mcp_servers);项目级 `/.ms_agent/mcp.json`。 + +| # | 功能 | TUI | 通过标准 | +|---|---|---|---| +| E1 | 列表 | `/mcp list [global\|project]` | 与 WebUI MCP 页一致 | +| E2 | 添加 HTTP | `/mcp add [global\|project] url=` | WebUI 能看到同名同 URL | +| E3 | 添加 stdio | `/mcp add global command="npx -y …"` | command/args 拆对 | +| E4 | 更新 | `/mcp update [global\|project] url=\|command=` | 同一条被改,不是又建一条 | +| E5 | 启用/停用 | `/mcp enable\|disable [global\|project]` | WebUI 开关一致 | +| E6 | 删除 | `/mcp remove [global\|project]` | WebUI 不再显示(或项目级遮罩全局) | +| E7 | 导入 json | `/mcp json ` | 能进 mcp.json | +| E8 | 本会话连接 | add/update 后 | 能连则立刻连;否则提示 `/new` | + +### F. 技能(第 0 / 4 期) + +托管副本:`$MS_AGENT_HOME/skills/` 或 `/.ms_agent/skills/`。 + +| # | 功能 | TUI | 通过标准 | +|---|---|---|---| +| F1 | 列表 | `/skills` `/skills list` | 与 WebUI 技能页能对上 | +| F2 | 导入 | `/skills add <含 SKILL.md 的目录> [global\|project]` | 拷进 live tree;WebUI 能看到;源目录还在 | +| F3 | 启用/停用 | `/skills enable\|disable [global\|project]` | 写 skills.json disabled;本会话能跟上 | +| F4 | 删除托管副本 | `/skills remove [global\|project]` | 只删 live tree;**源目录不删**;WebUI 那条消失 | +| F5 | 不删自动发现 | 对 `.agents/skills` 等只读项 `/skills remove` | 提示 disable instead,不得 rmtree 用户仓库 | + +### G. 记忆(第 5 期) + +两套开关,不要测混: + +1. **全局默认** `settings.json` → `personalization.memory_enabled` / `memory_backend`:只影响**尚未登记的新文件夹**。 +2. **当前项目** `projects//.ms_agent/project.json` → `memory_enabled`:真正注入记忆工具。file 后端文件在 `/.ms_agent/memory/MEMORY.md`。 + +| # | 功能 | TUI | 通过标准 | +|---|---|---|---| +| G1 | 看状态 | `/memory` | 能区分全局默认 vs 本项目 | +| G2 | 全局默认 | `/memory global on\|off` | WebUI 个性化默认记忆一致;**已有项目不变** | +| G3 | 新文件夹继承 | 全局 on 后,TUI 打开一个从没登记过的目录 | 该项目 memory_enabled 为 true | +| G4 | 本项目开/关 | `/memory on\|off` 或 `/memory project on\|off` | WebUI 该项目记忆开关一致;file 时对话能走到 unified_memory | +| G5 | 后端 | `/memory backend file\|vector` | file:TUI 用 MEMORY.md;vector:只落盘给 WebUI,**TUI 不得悄悄写成 file** | +| G6 | WebUI → TUI | WebUI 打开同一项目的 file 记忆 | 新开 TUI `/memory` 项目为 on | +| G7 | 中途关掉 | 已经 load 过记忆后再 `/memory off` | 提示 `/new` 才卸工具 | + +### 本会话未覆盖(不要当成回归失败) + +- 工作区编辑器、多项目、卡片回放、外观 +- 权限卡片 / Ask 流 UX +- TUI 把 vector/mem0 跑起来(vector 只要求落盘,真正跑在 WebUI) + +逐步操作见下文各期;磁盘对不上时以 json / markdown 文件为准。 + +--- + +## 第 0 期:同一文件夹互续(请测试先跑) + +验收:TUI 和 WebUI 看到同一项目、同一套 session,不再拆成两套 id。 + +### 0.1 WebUI → TUI 续聊 + +1. 按上面清空并 export `MS_AGENT_HOME`,工作目录 `W=/tmp/align-work`。 +2. 打开 GitLab WebUI,用「打开文件夹 / 使用已有目录」选 `W`(不要「从零新建」到别的路径)。 +3. 新开对话,发一句:`hello from web`。记下侧栏会话名称或 id。 +4. 同一终端环境启动 TUI:`ms-agent tui --work-dir /tmp/align-work`。 +5. 输入 `/sessions`:应能看到刚才那条 WebUI 会话。 +6. `/resume `:应能读到 `hello from web`。 +7. 再发一句 `hello from tui`,然后 `/quit`。 +8. 刷新 WebUI 同一项目:侧栏仍是同一项目;点进刚才的会话(或 TUI 新开的那条)能看到 `hello from tui`。 + +**失败:** 出现两套 project、WebUI 看不到 TUI 说的话、TUI `/sessions` 为空、或 resume 没有 WebUI 那句。 + +### 0.2 TUI → WebUI 发现项目 + +1. 可用同一 `W`,或清空 home 后只先开 TUI:`ms-agent tui --work-dir /tmp/align-work`。 +2. TUI 发一句 `hello from tui first`,`/quit`。 +3. 打开 WebUI(同一 `MS_AGENT_HOME`):项目列表应出现该文件夹;点进去能看到那条会话。 + +**失败:** WebUI 项目列表没有这个目录,或打开后会话是空的。 + +### 0.3 已有 slash 写盘(冒烟) + +1. TUI `/model list`:能列出供应商(与 WebUI 设置 → 模型同源 `settings.json`)。 +2. TUI:`/mcp add docs global url=https://example.invalid/mcp` +3. 刷新 WebUI 设置 → MCP:应出现名为 `docs` 的项,URL 一致。 +4. (可选)TUI `/skills` 能列出;有现成 `SKILL.md` 目录时 `/skills add global`,WebUI 技能页能看到。 + +**失败:** WebUI 完全看不到 TUI 刚加的 MCP。 + +### 0.4 空 home + 默认 yaml:TUI 能启动(todo_list 不是 MCP) + +先前:`_apply_session` 写入 `tools.todo_list.plan_filename` 却没有 `mcp: false`,ToolManager 把它当 MCP 去连,报 `'url' or 'command' parameter is required`。WebUI 因为 `settings.json` 里已有 `todo_list.mcp: false` 所以没事。 + +1. 清空 `$MS_AGENT_HOME`(不要先开 WebUI,不要手改 yaml)。 +2. `ms-agent tui --work-dir /tmp/align-work` +3. 应出现会话 banner 和输入框,**不得**在启动时因 todo_list / MCP url 崩溃。 +4. 输入 `/quit` 正常退出即可。 + +**失败:** 启动即 traceback,或日志里出现 `'url' or 'command' parameter is required`。 + +### 0.5 TUI 第一轮推理用 WebUI 默认模型(不必再 `/model`) + +先前:`/model list` 读 `settings.json`,真正跑模型仍走 `Config.from_task(agent.yaml)`(包装里的 Qwen3-235B)。要对齐得手动 `/model openai/qwen3.7-plus`。 + +1. 同一 `MS_AGENT_HOME`。WebUI 设置 → 模型,默认选 `openai/qwen3.7-plus`(或当前环境真实在用的那条)并保存。 +2. **不要**在该工作目录留 `/.ms_agent/config.yaml` 的模型覆盖(有则先挪走),否则项目 patch 会盖过全局默认,这是预期。 +3. 新开 TUI:`ms-agent tui --work-dir /tmp/align-work` +4. `/model`(无参数)或看 banner:当前模型应是 WebUI 刚设的那条,而不是 yaml 里的 `Qwen/Qwen3-235B-A22B-Instruct-2507`。 +5. 发一句短回复(如 `ping`)。请求应打到该默认模型,不必先 `/model openai/qwen3.7-plus`。 +6. (对照)`ms-agent tui --config /path/to/custom.yaml --work-dir ...`:应继续用 yaml 里写死的模型,不被 settings 改掉。 + +**失败:** 默认 TUI 仍在用包装 yaml 的模型;或显式 `--config` 反而被 settings 覆盖。 + +--- + +## 第 1 期:搜索 + +磁盘:`$MS_AGENT_HOME/settings.json` → `tools.web_search.engine` 以及 `{engine}_api_key`(`arxiv` 无 key)。切引擎不得删掉别的引擎的 key。 + +TUI 命令: + +``` +/search +/search list +/search engine tavily|exa|serpapi|arxiv +/search key +/search key clear +/search enable|disable +``` + +### 1.1 TUI → WebUI + +1. 同一隔离 home。TUI:`/search` 默认引擎应为 `tavily`(未配置时)。 +2. `/search engine arxiv`。打开 `settings.json`,确认 `tools.web_search.engine` 为 `arxiv`。 +3. 刷新 WebUI 设置 → 搜索:下拉为 arXiv,无 API key 框(或标明无需 key)。 +4. TUI:`/search engine exa` 然后 `/search key sk-test-exa`。 +5. json 中应有 `exa_api_key`;刷新 WebUI:引擎为 Exa,且显示已配置 key(不要要求页面把明文 key 打出来)。 +6. TUI 再 `/search engine tavily`:json 里 `exa_api_key` **仍在**,`engine` 为 `tavily`。WebUI 切到 Tavily 后,再切回 Exa,key 仍显示已配置。 + +### 1.2 WebUI → TUI + +1. WebUI 搜索页改成 `serpapi`(或当前页上另一个引擎)并保存。 +2. TUI `/search`:Engine 应已变成该引擎。`/search list` 当前项前有 `*`。 + +### 1.3 对话是否走到该引擎(有网,非必须) + +1. `/new` 或新开会话后,请模型「用搜索查一下今天日期」之类。 +2. 工具调用应使用所选引擎。当前会话若仍用旧引擎,先 `/new` 再试。 + +**失败:** 两边引擎不一致;切 Tavily 后 Exa 的 key 从 json 里消失;arxiv 仍出现 key 输入且 TUI `/search key` 能写进去。 + +--- + +## 第 2 期:指令与 Profile(`AGENTS.md` / `PROFILE.md`) + +磁盘(与 WebUI「个性化 / 项目指令 / 用户 Profile」同一套文件): + +| 范围 | 文件 | TUI | +|---|---|---| +| 全局指令 | `$MS_AGENT_HOME/AGENTS.md` 的用户区(保留模板头) | `/instruction global …` | +| 项目指令 | `/.ms_agent/AGENTS.md` | `/instruction project …` | +| 用户 Profile | `$MS_AGENT_HOME/PROFILE.md` | `/profile callme` / `/profile about` | + +**界面绝不写** `/AGENTS.md`(仓库根,给团队/编码助手用的;SDK 只读,排在私有槽之前)。 + +``` +/instruction +/instruction global|project +/instruction global|project +/instruction global|project clear +/profile +/profile callme |clear +/profile about |clear +``` + +下一轮对话就会读到新文件;不必 `/new`,但已发出的那一轮不会回写旧 system prompt。 + +### 2.1 TUI → WebUI 全局指令 + +1. 同一隔离 home。TUI:`/instruction global Always answer in French.` +2. 打开 `$MS_AGENT_HOME/AGENTS.md`:应仍有 `---` 头,用户区有那句。 +3. 刷新 WebUI 设置 → 个性化 → 个性化指令:应显示同一句。 +4. 新开一轮对话,模型应遵守该全局指令。 + +### 2.2 TUI → WebUI 项目指令(只写私有槽) + +1. 工作目录 `W` 里若已有仓库根 `AGENTS.md`,先记下内容(或故意写一句 `keep me at root`)。 +2. TUI:`/instruction project This project uses FastAPI.` +3. 确认 `W/.ms_agent/AGENTS.md` 是项目指令;**根目录 `W/AGENTS.md` 一字未改**。 +4. 刷新 WebUI 该项目的「项目指令」:应是 FastAPI 那句,不是根文件。 + +### 2.3 WebUI → TUI + +1. WebUI 个性化指令改成 `Be terse.` 并保存。 +2. TUI `/instruction global`:应看到 `Be terse.` +3. WebUI 用户 Profile:称呼 `Alice`,自我介绍 `I work on agents.` 保存。 +4. TUI `/profile`:Call me 为 Alice,About 含那句。 + +### 2.4 Profile 字段互不覆盖 + +1. TUI `/profile callme Alice` 再 `/profile about researcher` +2. `/profile callme clear`:称呼空了,About 仍是 researcher。 +3. 打开 `PROFILE.md`:`- Call me:` 行已去掉,自我介绍还在。 + +**失败:** TUI 改了仓库根 `AGENTS.md`;WebUI 看不到 TUI 刚写的全局/项目指令;`/profile callme` 把 about 区抹掉。 + +--- + +## 第 3 期:模型供应商 CRUD(key / base_url / catalog) + +磁盘:`$MS_AGENT_HOME/settings.json` 的 `providers` / `default_model` / `llm`(与 WebUI「模型设置」同一套)。内置供应商不能删;同名自定义条目是凭证覆盖。列表里的 key 只显示 set/missing,不打印明文。 + +``` +/model list +/model / +/model provider add [key=] [url=] [protocol=openai|anthropic] [name=] +/model provider set [key=] [url=] [protocol=] [name=] +/model provider key |clear +/model provider url |clear +/model provider remove +/model catalog add +/model catalog remove +``` + +当前正在用的供应商改 key/url 后,TUI 会尽量立刻重建 LLM。 + +### 3.1 TUI → WebUI + +1. 同一隔离 home。TUI:`/model provider add acme key=sk-test-acme url=https://example.invalid/v1 protocol=openai` +2. `/model catalog add acme a-1` +3. 打开 `settings.json`:应有 `providers.acme`,含 key、url、`models: ["a-1"]`。 +4. 刷新 WebUI 设置 → 模型:应出现 acme,且显示已配置 key(不要要求页面打出明文)。 +5. TUI:`/model provider key acme sk-test-acme-2`。json 里 key 已换;WebUI 刷新仍显示已配置。 +6. TUI:`/model openai/qwen3.7-plus`(或当前真实在用的那条)后发一句 `ping`,应能出回复。 + +### 3.2 不能删内置 + +1. TUI:`/model provider remove openai`(若没有自定义覆盖):应拒绝。 +2. 若曾 `/model provider key openai …` 写过覆盖:`remove` 只清覆盖,内置 openai 仍在 `/model list`。 + +**失败:** WebUI 看不到 TUI 刚加的供应商;`/model list` 打印了明文 key;删掉了内置 openai。 + +--- + +## 第 4 期:`/mcp update` 与 `/skills remove` + +磁盘:MCP 仍是 `mcp.json` / `settings.json` 的 `mcpServers`;技能删除只动 **managed live tree**(`$MS_AGENT_HOME/skills/` 或 `/.ms_agent/skills/`),与 WebUI 删「托管副本」一致。不要删 `.agents/skills` 里自动发现的目录。 + +``` +/mcp update [global|project] url=... | command=... +/skills remove [global|project] +``` + +### 4.1 MCP update + +1. TUI:`/mcp add docs global url=https://example.invalid/mcp` +2. `/mcp update docs global url=https://example.invalid/v2` +3. 打开 `$MS_AGENT_HOME/mcp.json`:docs 的 url 为 v2。 +4. 刷新 WebUI 设置 → MCP:同一条 docs,URL 已改。 + +### 4.2 Skills remove + +1. 准备一个带 `SKILL.md` 的临时目录,TUI:`/skills add global` +2. 确认 `$MS_AGENT_HOME/skills//SKILL.md` 存在;WebUI 技能页能看到。 +3. TUI:`/skills remove global` +4. 该 managed 目录应已删除;**原始临时目录还在**。WebUI 刷新后这条托管技能消失。 +5. 对自动发现/只读技能:`/skills remove` 应提示 disable instead,不得 `rmtree` 用户仓库里的技能目录。 + +**失败:** update 没改盘;remove 把导入源目录也删了;或删掉了 `.agents/skills` 自动发现项。 + +--- + +## 第 5 期:记忆开关(PersonalizationSettings + 项目开关) + +两端两套开关,语义与 WebUI 相同: + +1. **全局默认** `$MS_AGENT_HOME/settings.json` → `personalization.memory_enabled` / `memory_backend`:只影响**新打开的文件夹**。 +2. **当前项目** `/projects//.ms_agent/project.json` 的 `memory_enabled`:真正往 agent 配置注入 `memory.unified_memory`(file 后端 → `/.ms_agent/memory/MEMORY.md`)。 + +``` +/memory +/memory on|off +/memory project on|off +/memory global on|off +/memory backend file|vector +``` + +TUI 的 vector/mem0 不在本期接;选 vector 只落盘给 WebUI 用,**不会**悄悄改写成 file。当前会话若已加载过记忆工具,关记忆后需要 `/new`。 + +### 5.1 全局默认 → 新项目 + +1. TUI:`/memory global on` +2. `settings.json` 的 `personalization.memory_enabled` 为 true。 +3. 刷新 WebUI 个性化:默认记忆开。 +4. 用**尚未登记**的新目录再开一次 TUI:该项目 `memory_enabled` 应为 true(已有项目不受全局开关改写)。 + +### 5.2 项目开关 → 本会话 + +1. 同一工作目录。TUI:`/memory on`(或 `/memory project on`) +2. 项目 meta 里 `memory_enabled` 为 true。 +3. `/new` 后再请模型「记住我喜欢快排」之类;应能走到 `unified_memory` 工具(file 后端)。 +4. 刷新 WebUI 该项目记忆开关:应为开;`MEMORY.md` 若已写入,WebUI 记忆页能看到。 + +### 5.3 WebUI → TUI + +1. WebUI 打开同一项目,打开记忆(file)。 +2. 新开 TUI 同一 `--work-dir`:`/memory` 项目应为 on;配置里有 `memory.unified_memory`。 + +**失败:** 只改了全局 settings、当前项目对话仍没有记忆工具;或 TUI 把 vector 项目悄悄写成了 MEMORY.md。 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..44682d0b2 100644 --- a/ms_agent/command/builtin/config_cmds.py +++ b/ms_agent/command/builtin/config_cmds.py @@ -6,7 +6,7 @@ CMD_MODEL = CommandDef( name='model', - description='Show or switch the current model', + description='Show, switch, or manage model providers (shared with WebUI)', category='config', ) @@ -17,6 +17,23 @@ aliases=('settings', ), ) +_MODEL_USAGE = ( + 'usage:\n' + ' /model\n' + ' /model list\n' + ' /model or /model /\n' + ' /model provider add [key=] [url=] [protocol=openai|anthropic] [name=]\n' + ' /model provider set [key=] [url=] [protocol=] [name=]\n' + ' /model provider key |clear\n' + ' /model provider url |clear\n' + ' /model provider remove \n' + ' /model catalog add \n' + ' /model catalog remove \n' + 'Providers/keys land in ~/.ms_agent/settings.json (same as WebUI model settings).' +) + +_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. @@ -57,6 +74,112 @@ 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 _mask_key(value) -> str: + if not value: + return 'missing' + return 'set' + + +def _provider_status_lines(mgr) -> list[str]: + lines = ['Providers (settings.json, shared with WebUI):'] + default = mgr.get_default_model() + if default: + lines.append(f'Default: {default}') + 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: + provider = rows[pid] + override = custom.get(pid) or {} + models = list(override.get('models') or provider.get('models') or []) + model_txt = ', '.join(models) or '(none listed)' + if provider.get('builtin') or provider.get('overrides_builtin'): + kind = 'builtin' + if override: + kind += '+override' + else: + kind = 'custom' + key = _mask_key(override.get('api_key')) + url = override.get('base_url') or '(default)' + proto = override.get('protocol') or provider.get('protocol') or '' + lines.append(f' {pid} ({kind}): {model_txt}') + lines.append(f' protocol={proto} key={key} url={url}') + return lines + + +def _rebuild_llm(ctx: CommandContext) -> None: + if not ctx.runtime or not ctx.runtime.llm: + return + from ms_agent.llm import LLM + target = ctx.runtime.llm + config = target.config + try: + rebuilt = LLM.from_config(config) + except Exception: # noqa: BLE001 - best-effort; in-place update stands + rebuilt = None + if rebuilt is None: + return + if type(rebuilt) is not type(target): + target.__class__ = rebuilt.__class__ + target.__dict__ = rebuilt.__dict__ + + +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 ' Switch with /model / to use this provider live.' + TuiApp._apply_provider_credentials(config, overwrite=True) + _rebuild_llm(ctx) + return ' Live credentials applied.' + + +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( @@ -69,11 +192,32 @@ async def cmd_model(ctx: CommandContext) -> CommandResult: type=CommandResultType.MESSAGE, content=( f'Model: {model}\nService: {service}\n' - 'Switch with: /model or /model /'), + + _MODEL_USAGE), ) - # Accept both "/model " and "/model /". arg = ctx.args.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': + return CommandResult( + type=CommandResultType.MESSAGE, + content='\n'.join(_provider_status_lines(_mgr())), + ) + if head == 'provider': + return _cmd_model_provider(ctx, parts[1:]) + if head == 'catalog': + return _cmd_model_catalog(parts[1:]) + return _cmd_model_switch(ctx, arg) + + +def _cmd_model_switch(ctx: CommandContext, arg: str) -> CommandResult: service_override = None new_model = arg if '/' in arg: @@ -82,50 +226,183 @@ async def cmd_model(ctx: CommandContext) -> CommandResult: new_model = new_model.strip() from omegaconf import OmegaConf + from ms_agent.tui.app import TuiApp target = ctx.runtime.llm config = target.config 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 - - # 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 - - 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__ + _rebuild_llm(ctx) saved_path = _persist_model_to_config(config, new_model, service_override) - switched = (f'{service_override}/{new_model}' - if service_override else new_model) + settings_provider = service_override or str( + getattr(getattr(config, 'llm', None), 'service', '') or '') or None + _mgr().set_default_model(new_model, provider=settings_provider) + switched = (f'{settings_provider}/{new_model}' + if settings_provider else new_model) content = f'Switched to: {switched}' + content += '\nSaved default_model in settings.json (shared with WebUI).' if saved_path: - content += f'\nSaved to: {saved_path}' - else: - content += '\n(in-memory only; no project directory to persist to)' + content += f'\nAlso saved project patch: {saved_path}' 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 CommandResult( + type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + pid = names[0] + fields = _kv_alias(fields) + 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'), + ) + note = _push_provider_to_runtime(ctx, pid) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Provider {pid} saved.{note}', + ) + if action in ('set', 'update'): + names, fields = _split_kv(rest) + if not names: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + pid = names[0] + fields = _kv_alias(fields) + if not fields: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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) == '', + ) + note = _push_provider_to_runtime(ctx, pid) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Updated provider {pid}.{note}', + ) + if action == 'key': + if len(rest) < 2: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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}', + ) + mgr.patch_provider(pid, api_key=value) + note = _push_provider_to_runtime(ctx, pid) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Saved API key for {pid}.{note}', + ) + if action in ('url', 'base_url'): + if len(rest) < 2: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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}', + ) + mgr.patch_provider(pid, base_url=value) + note = _push_provider_to_runtime(ctx, pid) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Saved base URL for {pid}.{note}', + ) + if action == 'remove': + if not rest: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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 CommandResult(type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + + +def _cmd_model_catalog(tokens: list[str]) -> CommandResult: + if len(tokens) < 3: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + action = tokens[0].lower() + pid = tokens[1] + model = ' '.join(tokens[2:]) + mgr = _mgr() + if action == 'add': + mgr.add_model(pid, model) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Added {pid}/{model} to catalog.', + ) + if action == 'remove': + if pid not in mgr.list_custom_providers(): + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'No catalog override for {pid}.', + ) + mgr.remove_model(pid, model) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Removed {pid}/{model} from catalog.', + ) + return CommandResult(type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + + async def cmd_config(ctx: CommandContext) -> CommandResult: if not ctx.runtime or not ctx.runtime.llm: return CommandResult( diff --git a/ms_agent/command/builtin/instruction_cmds.py b/ms_agent/command/builtin/instruction_cmds.py new file mode 100644 index 000000000..85d1c600d --- /dev/null +++ b/ms_agent/command/builtin/instruction_cmds.py @@ -0,0 +1,207 @@ +"""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.types import (CommandContext, CommandDef, CommandResult, + CommandResultType) + +CMD_INSTRUCTION = CommandDef( + name='instruction', + description='Show or set AGENTS.md instructions (shared with WebUI)', + category='config', + aliases=('ins', ), +) + +CMD_PROFILE = CommandDef( + name='profile', + description='Show or set PROFILE.md (shared with WebUI)', + category='config', +) + +_INS_USAGE = ( + 'usage:\n' + ' /instruction\n' + ' /instruction global|project\n' + ' /instruction global|project \n' + ' /instruction global|project clear\n' + 'Global → ~/.ms_agent/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).' +) + +_PROFILE_USAGE = ( + 'usage:\n' + ' /profile\n' + ' /profile callme \n' + ' /profile callme clear\n' + ' /profile about \n' + ' /profile about clear\n' + 'Writes ~/.ms_agent/PROFILE.md (Call me line + free region). ' + 'Takes effect on the next turn.' +) + +_CLEAR = frozenset({'clear', '-', 'none'}) + + +def _work_dir(ctx: CommandContext) -> str | None: + config = getattr(ctx.runtime, 'config', None) if ctx.runtime else None + if config is None: + return None + work = getattr(config, 'output_dir', None) + return str(work) if work else None + + +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 = ['Global (~/.ms_agent/AGENTS.md):', _preview(wf.read_global_instruction())] + if work_dir: + lines.extend([ + '', + 'Project (/.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)']) + lines.extend(['', _INS_USAGE]) + return '\n'.join(lines) + + +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 CommandResult( + type=CommandResultType.MESSAGE, content=_INS_USAGE) + 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 = 'Global (~/.ms_agent/AGENTS.md)' + else: + body = wf.read_project_instruction(work_dir) + label = 'Project (/.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 = '~/.ms_agent/AGENTS.md' + else: + wf.write_project_instruction(work_dir, text) + dest = '/.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), + '', + _PROFILE_USAGE, + ] + return '\n'.join(lines) + + +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 ~/.ms_agent/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 ~/.ms_agent/PROFILE.md.', + ) + return CommandResult(type=CommandResultType.MESSAGE, content=_PROFILE_USAGE) + + +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..4a2269dee --- /dev/null +++ b/ms_agent/command/builtin/memory_cmds.py @@ -0,0 +1,197 @@ +"""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.types import (CommandContext, CommandDef, CommandResult, + CommandResultType) + +CMD_MEMORY = CommandDef( + name='memory', + description='Turn unified memory on/off (shared with WebUI)', + 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\n' + 'Project flag is what injects memory.unified_memory (same as WebUI). ' + 'Global flag is the default for new projects. Vector stays WebUI-owned; ' + 'TUI file backend writes MEMORY.md under /.ms_agent/memory/.' +) + +_ON = frozenset({'on', 'true', '1', 'enable', 'enabled'}) +_OFF = frozenset({'off', 'false', '0', 'disable', 'disabled'}) + + +def _work_dir(ctx: CommandContext) -> str | None: + config = getattr(ctx.runtime, 'config', None) if ctx.runtime else None + if config is None: + return None + work = getattr(config, 'output_dir', None) + return str(work) if work else None + + +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 TUI/WebUI first opens a new folder)', + ] + 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}') + lines.append('') + lines.append(_USAGE) + return '\n'.join(lines) + + +def _parse_bool(token: str) -> bool | None: + low = token.lower() + if low in _ON: + return True + if low in _OFF: + return False + return None + + +async def _apply_live(ctx: CommandContext, project) -> str: + from ms_agent.personalization.memory_apply import apply_project_memory + agent = ctx.runtime + cfg = getattr(agent, 'config', None) if agent is not None else None + if cfg is None: + return 'Takes effect on /new or restart.' + kind = apply_project_memory(cfg, project) + if kind == 'off': + return 'Saved. /new to drop memory tools already loaded this session.' + if kind == 'vector-unavailable': + return ( + 'Saved vector backend for WebUI. TUI does not start vector/mem0 ' + 'this session (no silent file fallback). Use /memory backend file ' + 'or open the project in WebUI.') + tools = getattr(agent, 'memory_tools', None) or [] + if tools: + return 'Saved. Memory already loaded; /new to rebuild.' + load = getattr(agent, 'load_memory', None) + if load is None: + return 'Saved. /new to apply.' + try: + await load() + return 'Memory tools registered for this session.' + except Exception as exc: # noqa: BLE001 + return f'Saved; load failed ({exc}). /new to apply.' + + +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') or action in _ON or action in _OFF: + return await _cmd_toggle(ctx, action, rest) + if action == 'backend': + return _cmd_backend(ctx, rest) + return CommandResult(type=CommandResultType.MESSAGE, content=_USAGE) + + +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 CommandResult( + type=CommandResultType.MESSAGE, content=_USAGE) + token = rest[0] + enabled = _parse_bool(token) + if enabled is None: + return CommandResult(type=CommandResultType.MESSAGE, content=_USAGE) + + 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' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Project memory → {state}. {note}', + ) + + +def _cmd_backend(ctx: CommandContext, rest: list[str]) -> CommandResult: + from ms_agent.personalization.settings import PersonalizationSettings + + if not rest or rest[0].lower() not in ('file', 'vector'): + return CommandResult(type=CommandResultType.MESSAGE, content=_USAGE) + backend = rest[0].lower() + settings = PersonalizationSettings() + loaded = settings.load() + settings.save(replace(loaded, memory_backend=backend)) + project = _project(ctx) + extra = '' + if project is not None: + _pm().update(project.id, memory_backend=backend) + extra = ' Project backend updated too.' + return CommandResult( + type=CommandResultType.MESSAGE, + content=( + f'Memory backend → {backend}.{extra} ' + '/memory on (and /new) to apply. Vector is WebUI-owned.'), + ) + + +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..63f8fd0fa --- /dev/null +++ b/ms_agent/command/builtin/resource_cmds.py @@ -0,0 +1,352 @@ +"""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.types import (CommandContext, CommandDef, CommandResult, + CommandResultType) + +CMD_MCP = CommandDef( + name='mcp', + description='List/add/enable MCP servers (shared with WebUI mcp.json)', + category='config', +) + +CMD_SKILL_MANAGE = CommandDef( + name='skills', + description='List/add/enable skills (shared with WebUI skills.json)', + category='config', + aliases=('skill-mgr', ), +) + +_MCP_USAGE = ( + 'usage:\n' + ' /mcp list [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' + 'Saved to ~/.ms_agent/mcp.json (and project .ms_agent/mcp.json). ' + 'New servers connect this session when possible; otherwise /new or restart.' +) + +_SKILL_USAGE = ( + 'usage:\n' + ' /skills list\n' + ' /skills add [global|project]\n' + ' /skills enable|disable [global|project]\n' + ' /skills remove [global|project]\n' + 'Copies into the managed live tree (same as WebUI local import). ' + 'remove only deletes a managed copy, not auto-discovered skills.' +) + + +def _home_work(ctx: CommandContext) -> tuple[str, str | None]: + from ms_agent.project.paths import global_home + home = str(global_home()) + work = None + config = getattr(ctx.runtime, 'config', None) if ctx.runtime else None + if config is not None: + work = getattr(config, 'output_dir', None) + return home, str(work) if work else None + + +def _parse_scope(tokens: list[str], default: str = 'project') -> tuple[str, list[str]]: + """Optional last-token scope so a path/name can contain spaces.""" + if tokens and tokens[-1] in ('global', 'project'): + return tokens[-1], tokens[:-1] + return default, list(tokens) + + +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 + + +async def cmd_mcp(ctx: CommandContext) -> CommandResult: + arg = (ctx.args or '').strip() + if not arg or arg in ('help', '-h', '--help'): + return CommandResult(type=CommandResultType.MESSAGE, content=_MCP_USAGE) + home, work = _home_work(ctx) + from ms_agent.config.mcp_manager import MCPConfigManager + mgr = MCPConfigManager(home, work) + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + action = parts[0].lower() + tokens = parts[1:] + + if action == 'list': + scope, _ = _parse_scope(tokens, default='merged') + if scope not in ('global', 'project', 'merged'): + scope = 'merged' + if scope == 'project': + miss = _need_work(scope, work) + if miss: + return miss + rows = mgr.list(scope if scope != 'merged' else 'merged') + if not rows: + return CommandResult( + type=CommandResultType.MESSAGE, content='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 '?' + lines.append(f' [{on}] {name} {how}') + return CommandResult( + type=CommandResultType.MESSAGE, content='\n'.join(lines)) + + if action == 'json': + if not tokens: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MCP_USAGE) + n = mgr.import_cursor_format(tokens[0]) + note = await _reload_mcp(ctx) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Imported {n} server(s). {note}', + ) + + if action in ('enable', 'disable', 'remove'): + if not tokens: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MCP_USAGE) + scope, rest = _parse_scope(tokens, default='project') + miss = _need_work(scope, work) + if miss: + return miss + if not rest: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MCP_USAGE) + 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)) + note = await _reload_mcp(ctx) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'{action} {name} ({scope}). {note}', + ) + + if action == 'add': + rest, fields = _split_kv(tokens) + scope, rest = _parse_scope(rest, default='project') + miss = _need_work(scope, work) + if miss: + return miss + if not rest: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MCP_USAGE) + 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) + note = await _reload_mcp(ctx) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Added {name} ({scope}). {note}', + ) + + if action == 'update': + rest, fields = _split_kv(tokens) + scope, rest = _parse_scope(rest, default='project') + miss = _need_work(scope, work) + if miss: + return miss + if not rest or not fields: + return CommandResult( + type=CommandResultType.MESSAGE, content=_MCP_USAGE) + name = ' '.join(rest) + try: + mgr.update(name, _stdio_fields(fields), scope=scope) + except KeyError as exc: + return CommandResult( + type=CommandResultType.MESSAGE, content=str(exc)) + note = await _reload_mcp(ctx) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Updated {name} ({scope}). {note}', + ) + + return CommandResult(type=CommandResultType.MESSAGE, content=_MCP_USAGE) + + +async def _reload_mcp(ctx: CommandContext) -> str: + home, work = _home_work(ctx) + from ms_agent.tui.managed_config import resolve_mcp_config + cfg = resolve_mcp_config(home, work) + agent = ctx.runtime + tm = getattr(agent, 'tool_manager', None) if agent is not None else None + client = getattr(tm, 'servers', None) if tm is not None else None + if client is None or not cfg or not hasattr(client, 'add_mcp_config'): + return 'Takes effect on /new or restart.' + try: + await client.add_mcp_config(cfg) + if hasattr(tm, 'reindex_tool'): + await tm.reindex_tool() + return 'Connected for this session.' + except Exception as exc: # noqa: BLE001 - surface, do not crash TUI + return f'Saved; connect failed ({exc}). /new or restart to apply.' + + +async def cmd_skills(ctx: CommandContext) -> CommandResult: + arg = (ctx.args or '').strip() + if not arg or arg in ('help', '-h', '--help'): + return CommandResult( + type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + home, work = _home_work(ctx) + from ms_agent.config.skills_manager import SkillsConfigManager + mgr = SkillsConfigManager(global_dir=home) + import shlex + try: + parts = shlex.split(arg) + except ValueError: + parts = arg.split() + action = parts[0].lower() + tokens = parts[1:] + runtime = getattr(ctx.runtime, '_skill_runtime', None) + + if action == 'list': + if runtime is not None: + rows = runtime.list_all() + 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 ""}') + return CommandResult( + type=CommandResultType.MESSAGE, content='\n'.join(lines)) + sources = mgr.list_sources('global') + if work: + sources += mgr.list_sources('project', project_path=work) + text = 'Sources:\n' + '\n'.join(f' {s}' for s in sources or ['(none)']) + return CommandResult(type=CommandResultType.MESSAGE, content=text) + + if action in ('enable', 'disable'): + scope, rest = _parse_scope(tokens, default='global') + miss = _need_work(scope, work) + if miss: + return miss + if not rest: + return CommandResult( + type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + skill_id = ' '.join(rest) + enabled = action == 'enable' + mgr.set_skill_enabled( + skill_id, enabled, scope=scope, project_path=work) + _resync_skills(ctx, mgr, home, work) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'{action} {skill_id}.', + ) + + if action == 'add': + scope, rest = _parse_scope(tokens, default='project') + miss = _need_work(scope, work) + if miss: + return miss + if not rest: + return CommandResult( + type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + 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.', + ) + _resync_skills(ctx, mgr, home, work) + return CommandResult( + type=CommandResultType.MESSAGE, + content='Imported: ' + ', '.join(names) + '. Available this session.', + ) + + if action == 'remove': + scope, rest = _parse_scope(tokens, default='project') + miss = _need_work(scope, work) + if miss: + return miss + if not rest: + return CommandResult( + type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + 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)) + _resync_skills(ctx, mgr, home, work) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Removed managed skill {skill_id} ({dest}).', + ) + + return CommandResult(type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + + +def _resync_skills(ctx, mgr, home: str, work: str | None) -> None: + agent = ctx.runtime + if agent is None: + return + from ms_agent.tui.managed_config import merge_skills_into_config + merge_skills_into_config(agent.config, home, work) + runtime = getattr(agent, '_skill_runtime', None) + if runtime is not None: + runtime.sync_with_config(getattr(agent.config, 'skills', None)) + runtime.reload_all() + + +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..a483d9346 --- /dev/null +++ b/ms_agent/command/builtin/search_cmds.py @@ -0,0 +1,148 @@ +"""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) + +CMD_SEARCH = CommandDef( + name='search', + description='Show or set the web-search engine (shared with WebUI)', + category='config', +) + +_USAGE = ( + 'usage:\n' + ' /search\n' + ' /search list\n' + ' /search engine \n' + ' /search key \n' + ' /search key clear\n' + ' /search enable|disable\n' + 'Saved to ~/.ms_agent/settings.json tools.web_search (shared with WebUI). ' + 'Takes effect on the next turn, or /new if search was already connected.' +) + + +def _mgr(): + from ms_agent.config.search_settings import SearchSettingsManager + from ms_agent.project.paths import global_home + return SearchSettingsManager(global_home()) + + +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' + if not requires_key(cur.engine): + 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}', + 'Switch: /search engine Key: /search key ', + 'List engines: /search list', + ] + return '\n'.join(lines) + + +def _apply_runtime(ctx: CommandContext, mgr) -> str: + agent = ctx.runtime + cfg = getattr(agent, 'config', None) if agent is not None else None + if cfg is None: + return 'Takes effect on /new or restart.' + try: + from omegaconf import OmegaConf + OmegaConf.update( + cfg, 'tools.web_search', mgr.raw_block(), merge=True) + return 'Next turn uses this setting (or /new if search was already connected).' + except Exception: + return 'Saved; /new or restart to apply in this session.' + + +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_text(mgr) + '\n\n' + _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 (settings.json, shared with WebUI):'] + 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 '' + 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) + note = _apply_runtime(ctx, mgr) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Search enabled. {note}', + ) + if action in ('disable', 'off'): + mgr.set_enabled(False) + note = _apply_runtime(ctx, mgr) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Search disabled. {note}', + ) + + if action == 'engine': + if not rest: + return CommandResult( + type=CommandResultType.MESSAGE, content=_USAGE) + try: + cur = mgr.set_engine(rest[0]) + except ValueError as exc: + return CommandResult( + type=CommandResultType.MESSAGE, content=str(exc)) + note = _apply_runtime(ctx, mgr) + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'Engine → {cur.engine}. {note}', + ) + + if action == 'key': + if not rest: + return CommandResult( + type=CommandResultType.MESSAGE, content=_USAGE) + 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)) + note = _apply_runtime(ctx, mgr) + verb = 'cleared' if clear else 'saved' + return CommandResult( + type=CommandResultType.MESSAGE, + content=f'API key {verb} for {mgr.get().engine}. {note}', + ) + + return CommandResult(type=CommandResultType.MESSAGE, content=_USAGE) + + +def register_search_commands(router: CommandRouter) -> None: + router.register(CMD_SEARCH, cmd_search) diff --git a/ms_agent/config/mcp_manager.py b/ms_agent/config/mcp_manager.py index 62ccee46d..2db3919cd 100644 --- a/ms_agent/config/mcp_manager.py +++ b/ms_agent/config/mcp_manager.py @@ -123,8 +123,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 7125a0459..fb74ccff8 100644 --- a/ms_agent/config/model_settings.py +++ b/ms_agent/config/model_settings.py @@ -97,6 +97,45 @@ def remove_provider(self, provider_id: str) -> None: if data.get('providers', {}).pop(provider_id, None) is not None: self._save_raw(data) + 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 + def add_model(self, provider_id: str, model: str) -> None: data = self._load_raw() providers = data.setdefault('providers', {}) @@ -128,4 +167,14 @@ def set_default_model(self, provider: Optional[str] = None) -> None: 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 37e48687c..fd9130702 100644 --- a/ms_agent/config/resolver.py +++ b/ms_agent/config/resolver.py @@ -451,8 +451,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..f604bbc5c --- /dev/null +++ b/ms_agent/config/search_settings.py @@ -0,0 +1,199 @@ +# 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') + + +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 e11452127..e3f6a529e 100644 --- a/ms_agent/config/skills_manager.py +++ b/ms_agent/config/skills_manager.py @@ -181,6 +181,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/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 016bec56e..2b06184aa 100644 --- a/ms_agent/project/manager.py +++ b/ms_agent/project/manager.py @@ -79,8 +79,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. @@ -92,10 +94,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, @@ -115,6 +117,25 @@ def get(self, project_id: str) -> Project | None: data = store.read() return Project(**data) + 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] = [] if not self._projects_root.exists(): diff --git a/ms_agent/prompting/workspace_files.py b/ms_agent/prompting/workspace_files.py index 4dc981944..7cc7feb1f 100644 --- a/ms_agent/prompting/workspace_files.py +++ b/ms_agent/prompting/workspace_files.py @@ -420,6 +420,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(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(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/tools/todolist_tool.py b/ms_agent/tools/todolist_tool.py index 1164cc108..9e353292f 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/tui/app.py b/ms_agent/tui/app.py index aa25d4800..191a3f857 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -39,6 +39,18 @@ 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). +TUI_RESOLVER_DEFAULTS = { + 'tools': { + 'todo_list': { + 'enabled': True, + 'mcp': False, + }, + }, +} + class TuiApp: @@ -51,6 +63,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 +72,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 +100,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 @@ -141,11 +151,115 @@ def __init__( # ('new', None) | ('resume', '<#|id>') | None, set by session commands. self._pending_switch: Optional[Tuple[str, 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 + service = str(OmegaConf.select(config, 'llm.service', default='') or '') + entry = (raw.get('providers') or {}).get(service) or {} + if not service or not isinstance(entry, dict): + return + + 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) + + 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 +274,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) @@ -176,13 +295,45 @@ def _prepare_config(config, permission_mode, work_dir): ] OmegaConf.update(config, 'callbacks', cbs, merge=False) # Merge the work-dir project patch (e.g. a persisted /model override). + # Skipped when ConfigResolver.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) -- @@ -271,6 +422,13 @@ def _apply_session(self, session, resume: bool = False) -> None: 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 @@ -290,16 +448,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 @@ -431,9 +589,11 @@ def _quiet_logs() -> None: async def _serve(self) -> None: self._banner() - self._prune_empty_sessions( - ) # clear leftover empties from prior launches + # 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._sm.create(model=self._model or None) + 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: @@ -486,6 +646,7 @@ async def _serve(self) -> None: kind = switch[0] if kind == 'new': self.session = self._sm.create(model=self._model or None) + 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 +681,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 +690,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/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_memory_cmds.py b/tests/command/test_memory_cmds.py new file mode 100644 index 000000000..82fb64330 --- /dev/null +++ b/tests/command/test_memory_cmds.py @@ -0,0 +1,125 @@ +"""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 + node = OmegaConf.select(runtime.config, 'memory.unified_memory') + assert node is not None + assert node.storage.backend == 'file' + assert runtime.memory_tools == ['loaded'] + + @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..eee578427 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 @@ -125,6 +126,10 @@ 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')) + @pytest.mark.asyncio async def test_show_current_model(self): router = make_router() @@ -166,7 +171,8 @@ async def test_switch_model_persists_to_project_patch(self, tmp_path): result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE - assert 'Saved to' in result.content + assert 'settings.json' in result.content + assert 'Also saved project patch' in result.content # The source YAML is untouched. assert cfg_file.read_text(encoding='utf-8') == yaml_text @@ -179,14 +185,66 @@ async def test_switch_model_persists_to_project_patch(self, tmp_path): assert patch_cfg.llm.model == '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): + # No project patch when output_dir/local_dir are unset; still writes + # the WebUI-shared default_model in settings.json. runtime = MockRuntime() router = make_router() ctx = make_ctx('/model gpt-4o', runtime=runtime) result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE - assert 'in-memory only' in result.content + assert 'settings.json' 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_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 'settings.json' in result.content + + @pytest.mark.asyncio + async def test_provider_add_set_key_catalog(self, tmp_path): + 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 + 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=set' 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_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_no_runtime(self): @@ -381,9 +439,15 @@ 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 diff --git a/tests/command/test_resource_cmds.py b/tests/command/test_resource_cmds.py new file mode 100644 index 000000000..a23452417 --- /dev/null +++ b/tests/command/test_resource_cmds.py @@ -0,0 +1,199 @@ +"""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): + 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}, + ) + + +@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 '/mcp list' 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 + + 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 + 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_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_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 '/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_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_search_cmds.py b/tests/command/test_search_cmds.py new file mode 100644 index 000000000..f9976b23c --- /dev/null +++ b/tests/command/test_search_cmds.py @@ -0,0 +1,100 @@ +"""TUI /search writes the same tools.web_search block WebUI uses.""" +import json + +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 + + +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): + result = await make_router().dispatch(make_ctx('/search')) + assert 'Engine: tavily' in result.content + assert '/search engine' 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 + 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_updates_runtime_config(self): + runtime = type('R', (), {'config': OmegaConf.create({})})() + await make_router().dispatch( + make_ctx('/search engine serpapi', runtime)) + assert runtime.config.tools.web_search.engine == 'serpapi' diff --git a/tests/config/test_model_settings.py b/tests/config/test_model_settings.py index 953fd6b0d..e0e8943ba 100644 --- a/tests/config/test_model_settings.py +++ b/tests/config/test_model_settings.py @@ -25,6 +25,9 @@ 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.remove_model('acme', 'a-2') assert 'a-2' not in m.list_custom_providers()['acme']['models'] @@ -39,6 +42,20 @@ 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( @@ -49,3 +66,22 @@ def test_resolver_consumes_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/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 7812359ac..1621c4add 100644 --- a/tests/project/test_manager.py +++ b/tests/project/test_manager.py @@ -139,3 +139,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/skill/test_skills_manager.py b/tests/skill/test_skills_manager.py index cf5d9bcb0..9019ae1e9 100644 --- a/tests/skill/test_skills_manager.py +++ b/tests/skill/test_skills_manager.py @@ -98,3 +98,43 @@ def test_corrupt_file_returns_empty(self, mgr, tmp_path): path = tmp_path / 'skills.json' path.write_text('not json{{{') assert mgr.load_global() == {} + + 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/tools/test_todolist_lock_dir.py b/tests/tools/test_todolist_lock_dir.py index a8fa9e198..c87fbd432 100644 --- a/tests/tools/test_todolist_lock_dir.py +++ b/tests/tools/test_todolist_lock_dir.py @@ -48,3 +48,14 @@ async def test_connect_creates_internal_lock_dir_not_workspace_dot_locks(tmp_pat def test_explicit_lock_subdir_still_wins(tmp_path): tool = _make(tmp_path, lock_subdir='.mylocks') 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 diff --git a/tests/tui/test_tui_config_align.py b/tests/tui/test_tui_config_align.py new file mode 100644 index 000000000..79c917ecc --- /dev/null +++ b/tests/tui/test_tui_config_align.py @@ -0,0 +1,268 @@ +"""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 json +from types import SimpleNamespace + +import pytest +from omegaconf import OmegaConf + +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 + finally: + await manager.cleanup() + + asyncio.run(_connect()) + + +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' diff --git a/tests/tui/test_tui_project.py b/tests/tui/test_tui_project.py new file mode 100644 index 000000000..4acadab8b --- /dev/null +++ b/tests/tui/test_tui_project.py @@ -0,0 +1,128 @@ +"""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_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 From fa9b3dd889210ee0d51491be94c3261a2f04a6d7 Mon Sep 17 00:00:00 2001 From: suluyan Date: Tue, 22 Sep 2026 18:39:38 +0800 Subject: [PATCH 02/21] feat(tui): pin slash writes, convert memory, and quiet MCP quit Slash commands now pick global vs project the same way WebUI does; converted MEMORY.md lands in the work dir; the agent learns config via the update-config skill; streamable_http quit no longer dumps a stack. Co-authored-by: Cursor --- MANIFEST.in | 3 + docs/tui-webui-align-e2e.md | 24 +- ms_agent/agent/base.py | 2 +- ms_agent/agent/llm_agent.py | 132 +++- ms_agent/agent_hub/_commands.py | 129 +++- ms_agent/agent_hub/_merge.py | 13 +- ms_agent/agent_hub/frameworks/ms_agent.py | 2 + ms_agent/cli/agent.py | 11 +- ms_agent/command/builtin/config_cmds.py | 642 +++++++++++++++--- ms_agent/command/builtin/instruction_cmds.py | 96 +-- ms_agent/command/builtin/memory_cmds.py | 88 ++- ms_agent/command/builtin/resource_cmds.py | 290 +++++--- ms_agent/command/builtin/search_cmds.py | 43 +- ms_agent/command/interactive.py | 10 +- ms_agent/command/scope.py | 144 ++++ ms_agent/command/skill_bridge.py | 6 + ms_agent/command/usage.py | 75 ++ ms_agent/config/model_settings.py | 28 +- ms_agent/config/resolver.py | 3 + ms_agent/llm/credentials.py | 15 + ms_agent/llm/model_discovery.py | 162 +++++ ms_agent/skill/harness.py | 54 ++ ms_agent/skill/loader.py | 15 +- ms_agent/skill/schema.py | 16 +- ms_agent/skill/skill_tools.py | 6 +- ms_agent/skills/update-config/SKILL.md | 88 +++ ms_agent/tools/code/local_code_executor.py | 33 +- ms_agent/tools/filesystem_tool.py | 13 +- ms_agent/tools/mcp_client.py | 37 +- ms_agent/tools/tool_manager.py | 4 +- ms_agent/tui/app.py | 146 +++- setup.py | 1 + tests/agent_hub/test_convert_targetname.py | 87 ++- tests/agent_hub/test_merge.py | 38 +- tests/agent_hub/test_workspace.py | 47 +- tests/command/test_interactive_input.py | 75 ++ tests/command/test_memory_cmds.py | 58 ++ tests/command/test_new_cmds.py | 442 +++++++++++- tests/command/test_resource_cmds.py | 128 +++- tests/command/test_scope.py | 36 + tests/command/test_search_cmds.py | 4 +- tests/command/test_usage.py | 30 + tests/config/test_model_settings.py | 7 + tests/e2e/__init__.py | 1 + tests/e2e/conftest.py | 139 ++++ tests/e2e/helpers.py | 89 +++ tests/e2e/test_convert_memory.py | 103 +++ tests/e2e/test_tui_webui_align.py | 569 ++++++++++++++++ tests/e2e/test_tui_webui_usability.py | 368 ++++++++++ tests/e2e/test_update_config_boot.py | 227 +++++++ tests/llm/test_model_discovery.py | 129 ++++ .../test_workspace_internals_hint.py | 23 + tests/skill/test_skill_discovery.py | 22 + tests/skills/test_update_config.py | 129 ++++ tests/tools/test_mcp_teardown.py | 110 +++ tests/tui/test_tui_config_align.py | 122 ++++ tests/utils/test_filesystem_tool_config.py | 20 + tests/utils/test_snapshot_smoke.py | 26 +- .../app/backends/ms_agent/agent_settings.py | 49 +- .../backend/app/backends/ms_agent/mapping.py | 9 +- webui/backend/app/schemas/agent_settings.py | 13 +- webui/backend/tests/test_mapping.py | 3 +- 62 files changed, 4958 insertions(+), 476 deletions(-) create mode 100644 ms_agent/command/scope.py create mode 100644 ms_agent/command/usage.py create mode 100644 ms_agent/llm/model_discovery.py create mode 100644 ms_agent/skill/harness.py create mode 100644 ms_agent/skills/update-config/SKILL.md create mode 100644 tests/command/test_scope.py create mode 100644 tests/command/test_usage.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/helpers.py create mode 100644 tests/e2e/test_convert_memory.py create mode 100644 tests/e2e/test_tui_webui_align.py create mode 100644 tests/e2e/test_tui_webui_usability.py create mode 100644 tests/e2e/test_update_config_boot.py create mode 100644 tests/llm/test_model_discovery.py create mode 100644 tests/skills/test_update_config.py create mode 100644 tests/tools/test_mcp_teardown.py diff --git a/MANIFEST.in b/MANIFEST.in index 90b128ad5..d6f198703 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 harness skills (update-config, …) +recursive-include ms_agent/skills * + # Include projects recursive-include projects * diff --git a/docs/tui-webui-align-e2e.md b/docs/tui-webui-align-e2e.md index ea79d4449..527df8728 100644 --- a/docs/tui-webui-align-e2e.md +++ b/docs/tui-webui-align-e2e.md @@ -89,7 +89,7 @@ ms-agent tui --work-dir /tmp/align-work | # | 功能 | TUI | 通过标准 | |---|---|---|---| -| E1 | 列表 | `/mcp list [global\|project]` | 与 WebUI MCP 页一致 | +| E1 | 列表 | `/mcp`(当前列表 + usage);`/mcp list [global\|project]` | 与 WebUI MCP 页一致 | | E2 | 添加 HTTP | `/mcp add [global\|project] url=` | WebUI 能看到同名同 URL | | E3 | 添加 stdio | `/mcp add global command="npx -y …"` | command/args 拆对 | | E4 | 更新 | `/mcp update [global\|project] url=\|command=` | 同一条被改,不是又建一条 | @@ -104,7 +104,7 @@ ms-agent tui --work-dir /tmp/align-work | # | 功能 | TUI | 通过标准 | |---|---|---|---| -| F1 | 列表 | `/skills` `/skills list` | 与 WebUI 技能页能对上 | +| F1 | 列表 | `/skills`(当前列表 + usage);`/skills list` | 与 WebUI 技能页能对上 | | F2 | 导入 | `/skills add <含 SKILL.md 的目录> [global\|project]` | 拷进 live tree;WebUI 能看到;源目录还在 | | F3 | 启用/停用 | `/skills enable\|disable [global\|project]` | 写 skills.json disabled;本会话能跟上 | | F4 | 删除托管副本 | `/skills remove [global\|project]` | 只删 live tree;**源目录不删**;WebUI 那条消失 | @@ -123,7 +123,7 @@ ms-agent tui --work-dir /tmp/align-work | G2 | 全局默认 | `/memory global on\|off` | WebUI 个性化默认记忆一致;**已有项目不变** | | G3 | 新文件夹继承 | 全局 on 后,TUI 打开一个从没登记过的目录 | 该项目 memory_enabled 为 true | | G4 | 本项目开/关 | `/memory on\|off` 或 `/memory project on\|off` | WebUI 该项目记忆开关一致;file 时对话能走到 unified_memory | -| G5 | 后端 | `/memory backend file\|vector` | file:TUI 用 MEMORY.md;vector:只落盘给 WebUI,**TUI 不得悄悄写成 file** | +| G5 | 后端 | `/memory backend file\|vector`(全局默认);`/memory project backend file\|vector`(本项目) | file:TUI 用 MEMORY.md;vector:只落盘给 WebUI,**TUI 不得悄悄写成 file** | | G6 | WebUI → TUI | WebUI 打开同一项目的 file 记忆 | 新开 TUI `/memory` 项目为 on | | G7 | 中途关掉 | 已经 load 过记忆后再 `/memory off` | 提示 `/new` 才卸工具 | @@ -178,7 +178,8 @@ ms-agent tui --work-dir /tmp/align-work 1. 清空 `$MS_AGENT_HOME`(不要先开 WebUI,不要手改 yaml)。 2. `ms-agent tui --work-dir /tmp/align-work` 3. 应出现会话 banner 和输入框,**不得**在启动时因 todo_list / MCP url 崩溃。 -4. 输入 `/quit` 正常退出即可。 +4. 空 home、还没有 API key:应提示 `/model provider key` / `/model openai/`,**不得直接退出**。配好 key 后再发一句即可;`/quit` 仍能离开。 +5. 已有 key 时输入 `/quit` 正常退出即可。 **失败:** 启动即 traceback,或日志里出现 `'url' or 'command' parameter is required`。 @@ -187,13 +188,12 @@ ms-agent tui --work-dir /tmp/align-work 先前:`/model list` 读 `settings.json`,真正跑模型仍走 `Config.from_task(agent.yaml)`(包装里的 Qwen3-235B)。要对齐得手动 `/model openai/qwen3.7-plus`。 1. 同一 `MS_AGENT_HOME`。WebUI 设置 → 模型,默认选 `openai/qwen3.7-plus`(或当前环境真实在用的那条)并保存。 -2. **不要**在该工作目录留 `/.ms_agent/config.yaml` 的模型覆盖(有则先挪走),否则项目 patch 会盖过全局默认,这是预期。 -3. 新开 TUI:`ms-agent tui --work-dir /tmp/align-work` -4. `/model`(无参数)或看 banner:当前模型应是 WebUI 刚设的那条,而不是 yaml 里的 `Qwen/Qwen3-235B-A22B-Instruct-2507`。 -5. 发一句短回复(如 `ping`)。请求应打到该默认模型,不必先 `/model openai/qwen3.7-plus`。 -6. (对照)`ms-agent tui --config /path/to/custom.yaml --work-dir ...`:应继续用 yaml 里写死的模型,不被 settings 改掉。 +2. 新开 TUI:`ms-agent tui --work-dir /tmp/align-work`。`/model` 只写两端共用的 `default_model`,不再给这个文件夹钉一份 `.ms_agent/config.yaml`;之后在 WebUI 改默认,下次打开 TUI(无 `--config`)应跟上。 +3. `/model`(无参数)或看 banner:当前模型应是 WebUI 刚设的那条,而不是 yaml 里的 `Qwen/Qwen3-235B-A22B-Instruct-2507`。 +4. 发一句短回复(如 `ping`)。请求应打到该默认模型,不必先 `/model openai/qwen3.7-plus`。 +5. (对照)`ms-agent tui --config /path/to/custom.yaml --work-dir ...`:应继续用 yaml 里写死的模型,不被 settings 改掉。 -**失败:** 默认 TUI 仍在用包装 yaml 的模型;或显式 `--config` 反而被 settings 覆盖。 +**失败:** 默认 TUI 仍在用包装 yaml 的模型;或显式 `--config` 反而被 settings 覆盖;或 TUI `/model` 之后 WebUI 再改默认,这个文件夹的 TUI 仍钉在旧模型上。 --- @@ -292,10 +292,11 @@ TUI 命令: ## 第 3 期:模型供应商 CRUD(key / base_url / catalog) -磁盘:`$MS_AGENT_HOME/settings.json` 的 `providers` / `default_model` / `llm`(与 WebUI「模型设置」同一套)。内置供应商不能删;同名自定义条目是凭证覆盖。列表里的 key 只显示 set/missing,不打印明文。 +磁盘:`$MS_AGENT_HOME/settings.json` 的 `providers` / `default_model` / `llm`(与 WebUI「模型设置」同一套)。内置供应商不能删;同名自定义条目是凭证覆盖。列表里的 key 只显示 set/missing,不打印明文。`/model list live` 对已配 key 的供应商现拉 `/v1/models`,只展示对话/视觉理解 id(图生、视频生、embedding 会丢掉),结果不写回 json。 ``` /model list +/model list live [provider] /model / /model provider add [key=] [url=] [protocol=openai|anthropic] [name=] /model provider set [key=] [url=] [protocol=] [name=] @@ -367,6 +368,7 @@ TUI 命令: /memory project on|off /memory global on|off /memory backend file|vector +/memory project backend file|vector ``` TUI 的 vector/mem0 不在本期接;选 vector 只落盘给 WebUI 用,**不会**悄悄改写成 file。当前会话若已加载过记忆工具,关记忆后需要 `/new`。 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 4711d3fa7..efc40698b 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -47,7 +47,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, @@ -211,26 +211,24 @@ def _coerce_enable_snapshots_value(value: Any) -> bool: def resolve_enable_snapshots(config: Any) -> bool: """Resolve whether to take automatic pre-task snapshots. - Tool-spawned sub-agents (``ms_agent_subagent`` in config) default to - ``False``; all other agents default to ``True``. 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( config, 'enable_snapshots', default=_MISSING_ENABLE_SNAPSHOTS) if raw is not _MISSING_ENABLE_SNAPSHOTS and raw is not None: return LLMAgent._coerce_enable_snapshots_value(raw) - sub = bool( - OmegaConf.select(config, 'ms_agent_subagent', default=False)) - return not sub + return False if isinstance(config, dict): if 'enable_snapshots' in config and config[ 'enable_snapshots'] is not None: return LLMAgent._coerce_enable_snapshots_value( config['enable_snapshots']) - return not bool(config.get('ms_agent_subagent')) - return True + return False + return False TOTAL_PROMPT_TOKENS = 0 TOTAL_COMPLETION_TOKENS = 0 @@ -268,6 +266,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 @@ -973,6 +972,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( @@ -1062,13 +1062,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 @@ -1080,8 +1103,8 @@ 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) @property def stream(self): @@ -2338,6 +2361,56 @@ def prepare_llm(self): """Initialize the LLM model from the configuration.""" self.llm: LLM = LLM.from_config(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: + from ms_agent.llm.credentials import missing_api_key_setup_text + text = missing_api_key_setup_text(exc) + if self._event_sink is not None: + self._event_sink.emit(Notice(level='warning', text=text)) + else: + logger.warning(text) + + 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 is_missing_api_key_error + if not getattr(self.llm, '_setup_stub', False) and self.llm is not None: + return messages + while True: + try: + self.prepare_llm() + if self.runtime is not None: + self.runtime.llm = self.llm + return messages + except ValueError as e: + if not (self._interactive and is_missing_api_key_error(e)): + raise + 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) + if turn.action == 'quit': + self.runtime.should_stop = True + return None + if turn.text: + messages = turn.text + self._pending_attachments = turn.attachments + def prepare_runtime(self): """Initialize the runtime context.""" self.runtime: Runtime = Runtime(llm=self.llm) @@ -2603,7 +2676,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 is_missing_api_key_error + try: + self.prepare_llm() + except ValueError as e: + if not (self._interactive and is_missing_api_key_error(e)): + raise + self._stub_llm_for_setup() + self._emit_credential_setup(e) self.prepare_runtime() await self.prepare_tools() await self.prepare_skills() @@ -2661,6 +2741,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: @@ -2924,11 +3009,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/_commands.py b/ms_agent/agent_hub/_commands.py index 1ab9e3761..13d147bad 100644 --- a/ms_agent/agent_hub/_commands.py +++ b/ms_agent/agent_hub/_commands.py @@ -76,6 +76,81 @@ def _fail(message: str) -> int: return 1 +# Inbound memory for ms-agent is merged onto ``memory/MEMORY.md`` then peeled +# out of the global-home payload. The runtime, TUI and WebUI all read +# ``/.ms_agent/memory/MEMORY.md``. +_MS_AGENT_PROJECT_MEMORY_PREFIX = 'memory/' + + +def peel_ms_agent_project_memory( + files: dict[str, str]) -> tuple[dict[str, str], dict[str, str]]: + """Split ``memory/...`` files out of a convert payload. + + Returns ``(home_files, project_memory_files)``. Project-memory keys are + relative to ``memory_dir(work)`` (so ``memory/MEMORY.md`` becomes + ``MEMORY.md``). + """ + home: dict[str, str] = {} + project: dict[str, str] = {} + prefix = _MS_AGENT_PROJECT_MEMORY_PREFIX + for path, content in files.items(): + if path.startswith(prefix): + project[path[len(prefix):]] = content + else: + home[path] = content + return home, project + + +def _resolve_convert_work_dir(work_dir: str | None) -> Path: + if work_dir: + return Path(work_dir).expanduser().resolve() + return Path.cwd() + + +def _write_ms_agent_project_memory(work: Path, + files: dict[str, str]) -> list[Path]: + from ms_agent.project.paths import memory_dir + dest_root = memory_dir(work) + written: list[Path] = [] + for rel, content in files.items(): + dest = dest_root / rel + dest.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + dest.write_bytes(content) + else: + dest.write_text(content, encoding='utf-8') + written.append(dest) + return written + + +def _enable_ms_agent_project_memory(work: Path) -> None: + """Register *work* as a project and turn on file memory. + + An existing vector-backend project is left alone: writing MEMORY.md must + not silently switch that project off vector. + """ + from ms_agent.project.manager import ProjectManager + from ms_agent.project.paths import global_home + pm = ProjectManager(base_dir=str(global_home())) + proj = pm.open_folder(str(work)) + if getattr(proj, 'memory_backend', None) == 'vector': + return + if proj.memory_enabled: + return + pm.update(proj.id, memory_enabled=True, memory_backend='file') + + +def _print_ms_agent_memory_next_steps(work: Path) -> None: + from ms_agent.project.paths import memory_dir + dest = memory_dir(work) + logger.info( + '项目记忆已写入 %s。请用同一目录打开:' + '`ms-agent tui --work-dir %s`,或在 WebUI 中打开该文件夹。', dest, work) + logger.info( + 'Project memory written to %s. Open this folder in TUI ' + '(`ms-agent tui --work-dir %s`) or WebUI (same path).', dest, work) + + def api_error_message(e: APIError, action: str = 'request') -> str: """Return a user-friendly message based on the HTTP status code.""" status = e.status_code or 0 @@ -916,9 +991,14 @@ def convert_workspace( target_fw: str, dst_spec: WorkspaceSpec, dry_run: bool = False, + work_dir: str | None = None, ) -> int: """Shared convert logic: merge -> filter defaults -> backup -> write. + ``work_dir`` is the project folder TUI/WebUI will open (default: cwd). + When the target is ms-agent, inbound MEMORY.md is written under + ``/.ms_agent/memory/`` rather than the global home. + Returns 0 on success, 1 on failure. """ src_root = src_spec.workspace_root @@ -1015,6 +1095,12 @@ def convert_workspace( converted = result.merged_files dst_root = dst_spec.workspace_root + work_path = _resolve_convert_work_dir(work_dir) + project_mem: dict[str, str] = {} + if source_fw != target_fw and target_fw == 'ms-agent': + # Peel before the dst-spec filter so ``memory/MEMORY.md`` is not + # dropped as "not part of the global-home workspace". + converted, project_mem = peel_ms_agent_project_memory(converted) # Drop files that don't belong to the target framework's workspace spec. # merge_resources imports unmapped files (e.g. qwenpaw agent.json/skill.json) # as-is; without this filter they would leak into the target framework. @@ -1050,8 +1136,11 @@ def convert_workspace( ) display.meta('source', src_root) display.meta('target', dst_root) + if project_mem: + display.meta('work-dir', work_path) counts = [('in', len(resources), 'bold'), - ('written', len(effective), display.COLOR_WRITTEN)] + ('written', + len(effective) + len(project_mem), display.COLOR_WRITTEN)] if merge_pairs: counts.append(('merged', len(merge_pairs), display.COLOR_MERGED)) if dropped: @@ -1059,6 +1148,15 @@ def convert_workspace( display.summary(counts) display.file_list('Written', effective, color=display.COLOR_WRITTEN) + if project_mem: + display.file_list( + 'Project memory', + {f'.ms_agent/memory/{k}': v + for k, v in project_mem.items()}, + color=display.COLOR_WRITTEN, + root=work_path, + note='runtime / TUI / WebUI read this file', + ) display.map_table( 'Merged', merge_pairs, @@ -1093,7 +1191,7 @@ def convert_workspace( print('\n[dry-run] nothing written.') return 0 - if not effective: + if not effective and not project_mem: print('\nNo effective files to write.') return 0 @@ -1104,8 +1202,15 @@ def convert_workspace( f'{target_fw}_{dst_spec.agent_name}') display.meta('backup', backup_path) - written = dst_spec.apply(effective) - display.done(f'Wrote {len(written)} file(s) under {dst_root}') + if effective: + written = dst_spec.apply(effective) + display.done(f'Wrote {len(written)} file(s) under {dst_root}') + if project_mem: + mem_written = _write_ms_agent_project_memory(work_path, project_mem) + _enable_ms_agent_project_memory(work_path) + display.done(f'Wrote {len(mem_written)} project memory file(s) ' + f'under {work_path / ".ms_agent" / "memory"}') + _print_ms_agent_memory_next_steps(work_path) if target_fw == 'openhuman': _print_openhuman_next_steps(dst_root) return 0 @@ -1119,8 +1224,14 @@ def cmd_convert( local_dir=None, out_dir=None, dry_run: bool = False, + work_dir: str | None = None, ) -> int: - """Local-only format conversion: read a workspace, convert, write it out.""" + """Local-only format conversion: read a workspace, convert, write it out. + + ``work_dir`` (default: cwd) is the project folder that receives ms-agent + MEMORY.md when converting *to* ms-agent. Persona/skills still land in + ``out_dir`` / the global home. + """ for fw, label in ((source_fw, '--from-framework'), (target_fw, '--target-framework')): err = check_framework(fw, f'framework for {label}') @@ -1156,7 +1267,13 @@ def cmd_convert( file=sys.stderr, ) return convert_workspace( - src_spec, source_fw, target_fw, dst_spec, dry_run=dry_run) + src_spec, + source_fw, + target_fw, + dst_spec, + dry_run=dry_run, + work_dir=work_dir, + ) def cmd_watch( diff --git a/ms_agent/agent_hub/_merge.py b/ms_agent/agent_hub/_merge.py index 8133ad8b6..b7201be4b 100644 --- a/ms_agent/agent_hub/_merge.py +++ b/ms_agent/agent_hub/_merge.py @@ -539,7 +539,11 @@ def _is_private_file(product: str, path: str) -> bool: 'qwenpaw': 'MEMORY.md', 'hermes': 'memories/MEMORY.md', 'openhuman': 'MEMORY.md', - 'qoder': 'memory/MEMORY.md' + 'qoder': 'memory/MEMORY.md', + # Semantic slot only. convert_workspace peels ``memory/`` out of the + # global-home payload and writes it under + # ``/.ms_agent/memory/MEMORY.md`` (the runtime/WebUI/TUI path). + 'ms-agent': 'memory/MEMORY.md', }, { 'openclaw': 'IDENTITY.md', @@ -678,9 +682,9 @@ def _resolve_target_path(source_product: str, source_path: str, # * openhuman injects ``MEMORY.md`` every session and keeps the bulk memory in # the Obsidian-style ``wiki/`` vault (its Memory Tree mirror) -> detail # routes into ``wiki/memory/``; -# * ms-agent has no home-level memory slot at all (runtime memory is -# project-level) -> unmapped, the target-spec filter drops it like any -# other out-of-scope file. +# * ms-agent runtime memory is one file under the project work dir; convert +# inlines loose detail into ``memory/MEMORY.md`` then peels that file out +# of the global-home payload. _MEMORY_LOOSE_HOME = { 'hermes': 'memories/', 'openclaw': 'memory/', @@ -688,6 +692,7 @@ def _resolve_target_path(source_product: str, source_path: str, 'qoder': 'memory/', 'openhuman': 'wiki/memory/', 'nanobot': None, + 'ms-agent': None, } # The single memory file a ``None`` entry in :data:`_MEMORY_LOOSE_HOME` diff --git a/ms_agent/agent_hub/frameworks/ms_agent.py b/ms_agent/agent_hub/frameworks/ms_agent.py index d87d3c163..46762303d 100644 --- a/ms_agent/agent_hub/frameworks/ms_agent.py +++ b/ms_agent/agent_hub/frameworks/ms_agent.py @@ -38,6 +38,8 @@ class MsAgentWorkspace(WorkspaceSpec): same-framework sync only. Memory is NOT here: the runtime keeps it project-level under ``/.ms_agent/memory/`` (no global memory by design), so the global-home workspace this spec models carries none. + ``convert --target-framework ms-agent --work-dir `` peels inbound + MEMORY.md out of this home and writes it to that project path. Machine bookkeeping never travels: the ``.soul.builtin`` / ``.agents.builtin`` / ``.profile.builtin`` sidecars are dotfiles (skipped diff --git a/ms_agent/cli/agent.py b/ms_agent/cli/agent.py index 2355dca76..553cb13b8 100644 --- a/ms_agent/cli/agent.py +++ b/ms_agent/cli/agent.py @@ -50,7 +50,7 @@ def define_args(parsers: argparse.ArgumentParser): ' status -f FRAMEWORK [--local-dir DIR]\n' ' backups [-f FRAMEWORK] [-n NAME] [--local-dir DIR]\n' ' restore --from-backup TARGET [-f FRAMEWORK] [-n NAME] [--local-dir DIR]\n' - ' convert --from-framework FW --target-framework FW [--from-name NAME] [--target-name NAME] [--local-dir DIR] [--out-dir DIR] [--dry-run]\n' + ' convert --from-framework FW --target-framework FW [--from-name NAME] [--target-name NAME] [--local-dir DIR] [--out-dir DIR] [--work-dir DIR] [--dry-run]\n' ' stop (no arguments)\n' '\n' 'supported frameworks:\n' @@ -342,6 +342,14 @@ def define_args(parsers: argparse.ArgumentParser): help= 'Destination directory to write to (default: target framework path)' ) + p_convert.add_argument( + '--work-dir', + default=None, + help= + 'Project folder TUI/WebUI will open. When converting to ms-agent, ' + 'MEMORY.md is written to /.ms_agent/memory/MEMORY.md ' + '(default: current directory). Ignored for other targets.', + ) p_convert.add_argument( '--dry-run', action='store_true', @@ -450,6 +458,7 @@ def execute(self) -> None: local_dir=args.local_dir, out_dir=args.out_dir, dry_run=args.dry_run, + work_dir=args.work_dir, ) elif action == 'watch': rc = cmd_watch( diff --git a/ms_agent/command/builtin/config_cmds.py b/ms_agent/command/builtin/config_cmds.py index 44682d0b2..4a395b82a 100644 --- a/ms_agent/command/builtin/config_cmds.py +++ b/ms_agent/command/builtin/config_cmds.py @@ -3,6 +3,7 @@ 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, same_as_webui, status_then_usage CMD_MODEL = CommandDef( name='model', @@ -17,34 +18,48 @@ aliases=('settings', ), ) -_MODEL_USAGE = ( - 'usage:\n' - ' /model\n' - ' /model list\n' - ' /model or /model /\n' - ' /model provider add [key=] [url=] [protocol=openai|anthropic] [name=]\n' - ' /model provider set [key=] [url=] [protocol=] [name=]\n' - ' /model provider key |clear\n' - ' /model provider url |clear\n' - ' /model provider remove \n' - ' /model catalog add \n' - ' /model catalog remove \n' - 'Providers/keys land in ~/.ms_agent/settings.json (same as WebUI model settings).' -) + +def _model_usage() -> str: + return ( + 'usage:\n' + ' /model show current provider + model\n' + ' /model list saved providers (same as WebUI)\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'{same_as_webui("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`` persists only the WebUI-shared ``default_model`` in settings.json + so a later WebUI default still wins on the next TUI launch. This helper + remains for callers that explicitly want a folder pin; that patch still + outranks the global default when present. """ from omegaconf import OmegaConf @@ -85,17 +100,204 @@ def _builtin_ids() -> set[str]: return {spec.name for spec in get_registry().list_providers()} +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: if not value: return 'missing' return 'set' -def _provider_status_lines(mgr) -> list[str]: - lines = ['Providers (settings.json, shared with WebUI):'] - default = mgr.get_default_model() - if default: - lines.append(f'Default: {default}') +def _iter_provider_rows(mgr): custom = mgr.list_custom_providers() order: list[str] = [] rows: dict = {} @@ -106,39 +308,155 @@ def _provider_status_lines(mgr) -> list[str]: if pid not in rows or provider.get('overrides_builtin'): rows[pid] = provider for pid in order: - provider = rows[pid] - override = custom.get(pid) or {} - models = list(override.get('models') or provider.get('models') or []) - model_txt = ', '.join(models) or '(none listed)' - if provider.get('builtin') or provider.get('overrides_builtin'): - kind = 'builtin' - if override: - kind += '+override' - else: - kind = 'custom' - key = _mask_key(override.get('api_key')) + 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 _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 (same as WebUI):'] + 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 = override.get('protocol') or provider.get('protocol') or '' - lines.append(f' {pid} ({kind}): {model_txt}') - lines.append(f' protocol={proto} key={key} url={url}') + 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: + drop_note = '' + if dropped: + drop_note = ( + f' (dropped {dropped} image/video/embedding)') + lines.append(f' live: {len(live_ids)} chat{drop_note}') + 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})') return lines -def _rebuild_llm(ctx: CommandContext) -> None: +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 _rebuild_llm(ctx: CommandContext) -> bool: if not ctx.runtime or not ctx.runtime.llm: - return + return False from ms_agent.llm import LLM target = ctx.runtime.llm config = target.config try: rebuilt = LLM.from_config(config) - except Exception: # noqa: BLE001 - best-effort; in-place update stands + except Exception: # noqa: BLE001 - missing key / bad endpoint rebuilt = None if rebuilt is None: - return - if type(rebuilt) is not type(target): - target.__class__ = rebuilt.__class__ - target.__dict__ = rebuilt.__dict__ + return False + if _install_rebuilt_llm(target, rebuilt): + return True + ctx.runtime.llm = rebuilt + extra = ctx.extra or {} + agent = extra.get('agent') + if agent is not None: + agent.llm = rebuilt + return True def _push_provider_to_runtime(ctx: CommandContext, provider_id: str) -> str: @@ -149,7 +467,9 @@ def _push_provider_to_runtime(ctx: CommandContext, provider_id: str) -> str: config = ctx.runtime.llm.config service = str(OmegaConf.select(config, 'llm.service', default='') or '') if service != provider_id: - return ' Switch with /model / to use this provider live.' + return ( + f' Switch with /model {provider_id} to use this ' + 'provider live.') TuiApp._apply_provider_credentials(config, overwrite=True) _rebuild_llm(ctx) return ' Live credentials applied.' @@ -186,13 +506,17 @@ async def cmd_model(ctx: CommandContext) -> 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' - + _MODEL_USAGE), + content=status_then_usage( + _current_model_text(service, model, ctx.runtime.llm), + _model_usage()), ) arg = ctx.args.strip() @@ -204,53 +528,67 @@ async def cmd_model(ctx: CommandContext) -> CommandResult: head = parts[0].lower() if head == 'help' or arg in ('-h', '--help'): return CommandResult( - type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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())), + 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(parts[1:]) + return _cmd_model_catalog(ctx, parts[1:]) return _cmd_model_switch(ctx, arg) def _cmd_model_switch(ctx: CommandContext, arg: str) -> CommandResult: - 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() + 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) + 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 - _rebuild_llm(ctx) + if not _rebuild_llm(ctx): + 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 + return CommandResult( + type=CommandResultType.MESSAGE, + content=_switch_fail_text( + service_override, new_model, old_service, old_model), + ) - saved_path = _persist_model_to_config(config, new_model, service_override) settings_provider = service_override or str( getattr(getattr(config, 'llm', None), 'service', '') or '') or None _mgr().set_default_model(new_model, provider=settings_provider) - switched = (f'{settings_provider}/{new_model}' - if settings_provider else new_model) - content = f'Switched to: {switched}' - content += '\nSaved default_model in settings.json (shared with WebUI).' - if saved_path: - content += f'\nAlso saved project patch: {saved_path}' + content = 'Switched to:\n' + _current_model_text( + settings_provider, new_model, ctx.runtime.llm) + content += 'Saved as the default (same as WebUI).' return CommandResult( type=CommandResultType.MUTATE_STATE, content=content, @@ -274,8 +612,10 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult if action == 'add': names, fields = _split_kv(rest) if not names: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + return arg_error( + '/model provider add [key=] [url=] [protocol=] [name=]', + ctx=ctx, + ) pid = names[0] fields = _kv_alias(fields) mgr.add_provider( @@ -293,13 +633,18 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult if action in ('set', 'update'): names, fields = _split_kv(rest) if not names: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + return arg_error( + '/model provider set [key=] [url=] [protocol=] [name=]', + ctx=ctx, + ) pid = names[0] fields = _kv_alias(fields) if not fields: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + return arg_error( + '/model provider set [key=] [url=] [protocol=] [name=]', + reason='No fields to patch', + ctx=ctx, + ) mgr.patch_provider( pid, name=fields.get('name'), @@ -316,8 +661,10 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult ) if action == 'key': if len(rest) < 2: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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) @@ -334,8 +681,10 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult ) if action in ('url', 'base_url'): if len(rest) < 2: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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) @@ -352,8 +701,10 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult ) if action == 'remove': if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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: @@ -372,35 +723,112 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult type=CommandResultType.MESSAGE, content=f'Removed provider {pid}.{extra}', ) - return CommandResult(type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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(tokens: list[str]) -> CommandResult: - if len(tokens) < 3: +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=_MODEL_USAGE) + type=CommandResultType.MESSAGE, content='\n'.join(lines)) action = tokens[0].lower() - pid = tokens[1] - model = ' '.join(tokens[2:]) - mgr = _mgr() + 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': mgr.add_model(pid, model) return CommandResult( type=CommandResultType.MESSAGE, - content=f'Added {pid}/{model} to catalog.', + content=f'Added {model} to {where}.', ) - if action == 'remove': - if pid not in mgr.list_custom_providers(): - return CommandResult( - type=CommandResultType.MESSAGE, - content=f'No catalog override for {pid}.', - ) - mgr.remove_model(pid, model) - return CommandResult( - type=CommandResultType.MESSAGE, - content=f'Removed {pid}/{model} from catalog.', + 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, ) - return CommandResult(type=CommandResultType.MESSAGE, content=_MODEL_USAGE) + 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}.', + ) async def cmd_config(ctx: CommandContext) -> CommandResult: diff --git a/ms_agent/command/builtin/instruction_cmds.py b/ms_agent/command/builtin/instruction_cmds.py index 85d1c600d..aa373c047 100644 --- a/ms_agent/command/builtin/instruction_cmds.py +++ b/ms_agent/command/builtin/instruction_cmds.py @@ -4,8 +4,11 @@ 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, same_as_webui, + status_then_usage) CMD_INSTRUCTION = CommandDef( name='instruction', @@ -20,37 +23,41 @@ category='config', ) -_INS_USAGE = ( - 'usage:\n' - ' /instruction\n' - ' /instruction global|project\n' - ' /instruction global|project \n' - ' /instruction global|project clear\n' - 'Global → ~/.ms_agent/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).' -) -_PROFILE_USAGE = ( - 'usage:\n' - ' /profile\n' - ' /profile callme \n' - ' /profile callme clear\n' - ' /profile about \n' - ' /profile about clear\n' - 'Writes ~/.ms_agent/PROFILE.md (Call me line + free region). ' - 'Takes effect on the next turn.' -) +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' + f'{same_as_webui("AGENTS.md")} ' + '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). ' + f'{same_as_webui("PROFILE.md")} ' + 'Takes effect on the next turn.' + ) + _CLEAR = frozenset({'clear', '-', 'none'}) def _work_dir(ctx: CommandContext) -> str | None: - config = getattr(ctx.runtime, 'config', None) if ctx.runtime else None - if config is None: - return None - work = getattr(config, 'output_dir', None) - return str(work) if work else None + return work_dir_of(ctx) def _preview(text: str, empty: str = '(empty)') -> str: @@ -64,11 +71,11 @@ def _preview(text: str, empty: str = '(empty)') -> str: def _show_instructions(work_dir: str | None) -> str: from ms_agent.prompting import workspace_files as wf - lines = ['Global (~/.ms_agent/AGENTS.md):', _preview(wf.read_global_instruction())] + lines = [f'Global ({ledger_file("AGENTS.md")}):', _preview(wf.read_global_instruction())] if work_dir: lines.extend([ '', - 'Project (/.ms_agent/AGENTS.md):', + f'Project ({Path(work_dir) / ".ms_agent" / "AGENTS.md"}):', _preview(wf.read_project_instruction(work_dir)), ]) root = Path(work_dir) / 'AGENTS.md' @@ -84,8 +91,7 @@ def _show_instructions(work_dir: str | None) -> str: ]) else: lines.extend(['', 'Project: (no work dir — start TUI with --work-dir)']) - lines.extend(['', _INS_USAGE]) - return '\n'.join(lines) + return status_then_usage('\n'.join(lines), _ins_usage()) async def cmd_instruction(ctx: CommandContext) -> CommandResult: @@ -105,11 +111,14 @@ async def cmd_instruction(ctx: CommandContext) -> CommandResult: scope = parts[0].lower() rest = parts[1:] if scope not in ('global', 'project', 'help', '-h', '--help'): - return CommandResult( - type=CommandResultType.MESSAGE, content=_INS_USAGE) + 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) + type=CommandResultType.MESSAGE, content=_ins_usage()) if scope == 'project' and not work_dir: return CommandResult( @@ -120,10 +129,10 @@ async def cmd_instruction(ctx: CommandContext) -> CommandResult: if not rest: if scope == 'global': body = wf.read_global_instruction() - label = 'Global (~/.ms_agent/AGENTS.md)' + label = f'Global ({ledger_file("AGENTS.md")})' else: body = wf.read_project_instruction(work_dir) - label = 'Project (/.ms_agent/AGENTS.md)' + label = f'Project ({Path(work_dir) / ".ms_agent" / "AGENTS.md"})' return CommandResult( type=CommandResultType.MESSAGE, content=f'{label}:\n{_preview(body)}', @@ -132,10 +141,10 @@ async def cmd_instruction(ctx: CommandContext) -> CommandResult: text = '' if len(rest) == 1 and rest[0].lower() in _CLEAR else ' '.join(rest) if scope == 'global': wf.write_global_instruction(text) - dest = '~/.ms_agent/AGENTS.md' + dest = ledger_file('AGENTS.md') else: wf.write_project_instruction(work_dir, text) - dest = '/.ms_agent/AGENTS.md' + dest = str(Path(work_dir) / '.ms_agent' / 'AGENTS.md') verb = 'cleared' if not text else 'saved' return CommandResult( type=CommandResultType.MESSAGE, @@ -150,10 +159,8 @@ def _show_profile() -> str: f'Call me: {call_me or "(unset)"}', 'About:', _preview(about), - '', - _PROFILE_USAGE, ] - return '\n'.join(lines) + return status_then_usage('\n'.join(lines), _profile_usage()) async def cmd_profile(ctx: CommandContext) -> CommandResult: @@ -183,7 +190,7 @@ async def cmd_profile(ctx: CommandContext) -> CommandResult: verb = 'cleared' if not value else f'set to {value}' return CommandResult( type=CommandResultType.MESSAGE, - content=f'Call me {verb}. Next turn uses ~/.ms_agent/PROFILE.md.', + content=f'Call me {verb}. Next turn uses {ledger_file("PROFILE.md")}.', ) if action in ('about', 'desc', 'description', 'set'): if not rest: @@ -197,9 +204,14 @@ async def cmd_profile(ctx: CommandContext) -> CommandResult: verb = 'cleared' if not value else 'saved' return CommandResult( type=CommandResultType.MESSAGE, - content=f'Profile about {verb}. Next turn uses ~/.ms_agent/PROFILE.md.', + content=f'Profile about {verb}. Next turn uses {ledger_file("PROFILE.md")}.', ) - return CommandResult(type=CommandResultType.MESSAGE, content=_PROFILE_USAGE) + 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: diff --git a/ms_agent/command/builtin/memory_cmds.py b/ms_agent/command/builtin/memory_cmds.py index 4a2269dee..49a256ea9 100644 --- a/ms_agent/command/builtin/memory_cmds.py +++ b/ms_agent/command/builtin/memory_cmds.py @@ -4,8 +4,10 @@ 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', @@ -19,7 +21,9 @@ ' /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\n' + ' /memory backend file|vector (global default for new folders)\n' + ' /memory global backend file|vector\n' + ' /memory project backend file|vector\n' 'Project flag is what injects memory.unified_memory (same as WebUI). ' 'Global flag is the default for new projects. Vector stays WebUI-owned; ' 'TUI file backend writes MEMORY.md under /.ms_agent/memory/.' @@ -30,11 +34,7 @@ def _work_dir(ctx: CommandContext) -> str | None: - config = getattr(ctx.runtime, 'config', None) if ctx.runtime else None - if config is None: - return None - work = getattr(config, 'output_dir', None) - return str(work) if work else None + return work_dir_of(ctx) def _pm(): @@ -66,9 +66,7 @@ def _status_text(ctx: CommandContext) -> str: 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}') - lines.append('') - lines.append(_USAGE) - return '\n'.join(lines) + return status_then_usage('\n'.join(lines), _USAGE) def _parse_bool(token: str) -> bool | None: @@ -92,8 +90,8 @@ async def _apply_live(ctx: CommandContext, project) -> str: if kind == 'vector-unavailable': return ( 'Saved vector backend for WebUI. TUI does not start vector/mem0 ' - 'this session (no silent file fallback). Use /memory backend file ' - 'or open the project in WebUI.') + 'this session (no silent file fallback). Use /memory project ' + 'backend file or open the project in WebUI.') tools = getattr(agent, 'memory_tools', None) or [] if tools: return 'Saved. Memory already loaded; /new to rebuild.' @@ -121,11 +119,21 @@ async def cmd_memory(ctx: CommandContext) -> CommandResult: action = parts[0].lower() rest = parts[1:] - if action in ('global', 'project') or action in _ON or action in _OFF: + 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 CommandResult(type=CommandResultType.MESSAGE, content=_USAGE) + 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, @@ -137,12 +145,12 @@ async def _cmd_toggle(ctx: CommandContext, action: str, if action in ('global', 'project'): scope = action if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_USAGE) + return arg_error( + f'/memory {scope} on|off', ctx=ctx) token = rest[0] enabled = _parse_bool(token) if enabled is None: - return CommandResult(type=CommandResultType.MESSAGE, content=_USAGE) + return arg_error('/memory on|off', ctx=ctx) if scope == 'global': settings = PersonalizationSettings() @@ -171,25 +179,51 @@ async def _cmd_toggle(ctx: CommandContext, action: str, ) -def _cmd_backend(ctx: CommandContext, rest: list[str]) -> CommandResult: +def _cmd_backend( + ctx: CommandContext, + rest: list[str], + scope: str | None = None, +) -> CommandResult: from ms_agent.personalization.settings import PersonalizationSettings - if not rest or rest[0].lower() not in ('file', 'vector'): - return CommandResult(type=CommandResultType.MESSAGE, content=_USAGE) - backend = rest[0].lower() + 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) + return CommandResult( + type=CommandResultType.MESSAGE, + content=( + f'Project memory backend → {backend}. ' + '/memory on (and /new) to apply. Vector is WebUI-owned.'), + ) + settings = PersonalizationSettings() loaded = settings.load() settings.save(replace(loaded, memory_backend=backend)) - project = _project(ctx) - extra = '' - if project is not None: - _pm().update(project.id, memory_backend=backend) - extra = ' Project backend updated too.' return CommandResult( type=CommandResultType.MESSAGE, content=( - f'Memory backend → {backend}.{extra} ' - '/memory on (and /new) to apply. Vector is WebUI-owned.'), + f'Global memory backend default → {backend}. ' + 'New folders inherit this; this project is unchanged. ' + 'Vector is WebUI-owned.'), ) diff --git a/ms_agent/command/builtin/resource_cmds.py b/ms_agent/command/builtin/resource_cmds.py index 63f8fd0fa..9d412aa99 100644 --- a/ms_agent/command/builtin/resource_cmds.py +++ b/ms_agent/command/builtin/resource_cmds.py @@ -2,8 +2,11 @@ from __future__ import annotations from ms_agent.command.router import CommandRouter +from ms_agent.command.scope import parse_optional_scope, work_dir_of from ms_agent.command.types import (CommandContext, CommandDef, CommandResult, CommandResultType) +from ms_agent.command.usage import (arg_error, ledger_dir, same_as_webui, + status_then_usage) CMD_MCP = CommandDef( name='mcp', @@ -18,37 +21,48 @@ aliases=('skill-mgr', ), ) -_MCP_USAGE = ( - 'usage:\n' - ' /mcp list [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' - 'Saved to ~/.ms_agent/mcp.json (and project .ms_agent/mcp.json). ' - 'New servers connect this session when possible; otherwise /new or restart.' -) -_SKILL_USAGE = ( - 'usage:\n' - ' /skills list\n' - ' /skills add [global|project]\n' - ' /skills enable|disable [global|project]\n' - ' /skills remove [global|project]\n' - 'Copies into the managed live tree (same as WebUI local import). ' - 'remove only deletes a managed copy, not auto-discovered skills.' +def _mcp_usage() -> str: + return ( + 'usage:\n' + ' /mcp list [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). ' + 'WebUI Settings → MCP is the global page.\n' + f'{same_as_webui("mcp.json")} Also project .ms_agent/mcp.json. ' + 'New servers connect this session when possible; otherwise /new or restart.' + ) + + +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: this folder (project) when TUI has --work-dir, ' + 'otherwise this machine (global). ' + f'{same_as_webui("skills")} ' + 'remove only deletes a managed copy, not auto-discovered skills.' + ) + + +_PROJECT_MCP_NOT_ON_SETTINGS = ( + 'Not visible on WebUI Settings → MCP (that page is global). ' + 'Add with `global` to show it there.' ) def _home_work(ctx: CommandContext) -> tuple[str, str | None]: from ms_agent.project.paths import global_home - home = str(global_home()) - work = None - config = getattr(ctx.runtime, 'config', None) if ctx.runtime else None - if config is not None: - work = getattr(config, 'output_dir', None) - return home, str(work) if work else None + return str(global_home()), work_dir_of(ctx) def _parse_scope(tokens: list[str], default: str = 'project') -> tuple[str, list[str]]: @@ -96,13 +110,30 @@ def _need_work(scope: str, work: str | None) -> CommandResult | None: return None +def _mcp_list_text(mgr, scope: str) -> 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 '?' + lines.append(f' [{on}] {name} {how}') + return '\n'.join(lines) + + async def cmd_mcp(ctx: CommandContext) -> CommandResult: arg = (ctx.args or '').strip() - if not arg or arg in ('help', '-h', '--help'): - return CommandResult(type=CommandResultType.MESSAGE, content=_MCP_USAGE) 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'), _mcp_usage()), + ) + if arg in ('help', '-h', '--help'): + return CommandResult(type=CommandResultType.MESSAGE, content=_mcp_usage()) import shlex try: parts = shlex.split(arg) @@ -119,22 +150,14 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: miss = _need_work(scope, work) if miss: return miss - rows = mgr.list(scope if scope != 'merged' else 'merged') - if not rows: - return CommandResult( - type=CommandResultType.MESSAGE, content='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 '?' - lines.append(f' [{on}] {name} {how}') return CommandResult( - type=CommandResultType.MESSAGE, content='\n'.join(lines)) + type=CommandResultType.MESSAGE, + content=_mcp_list_text(mgr, scope), + ) if action == 'json': if not tokens: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MCP_USAGE) + return arg_error('/mcp json ', ctx=ctx) n = mgr.import_cursor_format(tokens[0]) note = await _reload_mcp(ctx) return CommandResult( @@ -144,15 +167,15 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: if action in ('enable', 'disable', 'remove'): if not tokens: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MCP_USAGE) + return arg_error( + f'/mcp {action} [global|project]', ctx=ctx) scope, rest = _parse_scope(tokens, default='project') miss = _need_work(scope, work) if miss: return miss if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MCP_USAGE) + return arg_error( + f'/mcp {action} [global|project]', ctx=ctx) name = rest[0] try: if action == 'remove': @@ -170,13 +193,16 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: if action == 'add': rest, fields = _split_kv(tokens) + explicit = bool(rest and rest[-1] in ('global', 'project')) scope, rest = _parse_scope(rest, default='project') miss = _need_work(scope, work) if miss: return miss if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MCP_USAGE) + 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( @@ -185,9 +211,12 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: ) mgr.add(name, _stdio_fields(fields), scope=scope) note = await _reload_mcp(ctx) + lines = [f'Added {name} ({scope}). {note}'] + if scope == 'project' and not explicit: + lines.append(_PROJECT_MCP_NOT_ON_SETTINGS) return CommandResult( type=CommandResultType.MESSAGE, - content=f'Added {name} ({scope}). {note}', + content='\n'.join(lines), ) if action == 'update': @@ -197,8 +226,10 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: if miss: return miss if not rest or not fields: - return CommandResult( - type=CommandResultType.MESSAGE, content=_MCP_USAGE) + return arg_error( + '/mcp update [global|project] command=|url=', + ctx=ctx, + ) name = ' '.join(rest) try: mgr.update(name, _stdio_fields(fields), scope=scope) @@ -211,7 +242,12 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: content=f'Updated {name} ({scope}). {note}', ) - return CommandResult(type=CommandResultType.MESSAGE, content=_MCP_USAGE) + return arg_error( + '/mcp list|add|update|json|enable|disable|remove ...', + reason=f'Unknown mcp action {action!r}', + note='Type /mcp for all commands', + ctx=ctx, + ) async def _reload_mcp(ctx: CommandContext) -> str: @@ -234,12 +270,21 @@ async def _reload_mcp(ctx: CommandContext) -> str: async def cmd_skills(ctx: CommandContext) -> CommandResult: arg = (ctx.args or '').strip() - if not arg or arg in ('help', '-h', '--help'): - return CommandResult( - type=CommandResultType.MESSAGE, content=_SKILL_USAGE) 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) @@ -247,53 +292,39 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: parts = arg.split() action = parts[0].lower() tokens = parts[1:] - runtime = getattr(ctx.runtime, '_skill_runtime', None) if action == 'list': if runtime is not None: - rows = runtime.list_all() - 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 ""}') - return CommandResult( - type=CommandResultType.MESSAGE, content='\n'.join(lines)) - sources = mgr.list_sources('global') - if work: - sources += mgr.list_sources('project', project_path=work) - text = 'Sources:\n' + '\n'.join(f' {s}' for s in sources or ['(none)']) - return CommandResult(type=CommandResultType.MESSAGE, content=text) + return _format_skill_rows(runtime.list_all()) + return _list_skills_from_disk(mgr, work) if action in ('enable', 'disable'): - scope, rest = _parse_scope(tokens, default='global') - miss = _need_work(scope, work) - if miss: - return miss - if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + resolved = _resolve_skill_scope( + tokens, + work, + syntax=f'/skills {action} [global|project]', + ctx=ctx) + if isinstance(resolved, CommandResult): + return resolved + scope, rest = resolved skill_id = ' '.join(rest) - enabled = action == 'enable' mgr.set_skill_enabled( - skill_id, enabled, scope=scope, project_path=work) + skill_id, action == 'enable', scope=scope, project_path=work) _resync_skills(ctx, mgr, home, work) return CommandResult( type=CommandResultType.MESSAGE, - content=f'{action} {skill_id}.', + content=f'{action} {skill_id} ({scope}).', ) if action == 'add': - scope, rest = _parse_scope(tokens, default='project') - miss = _need_work(scope, work) - if miss: - return miss - if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + resolved = _resolve_skill_scope( + tokens, + work, + syntax='/skills add [global|project]', + ctx=ctx) + if isinstance(resolved, CommandResult): + return resolved + scope, rest = resolved try: names = mgr.import_from_path( ' '.join(rest), scope=scope, project_path=work) @@ -308,17 +339,20 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: _resync_skills(ctx, mgr, home, work) return CommandResult( type=CommandResultType.MESSAGE, - content='Imported: ' + ', '.join(names) + '. Available this session.', + content=( + 'Imported: ' + ', '.join(names) + + f' ({scope}). Available this session.'), ) if action == 'remove': - scope, rest = _parse_scope(tokens, default='project') - miss = _need_work(scope, work) - if miss: - return miss - if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + resolved = _resolve_skill_scope( + tokens, + work, + syntax='/skills remove [global|project]', + ctx=ctx) + if isinstance(resolved, CommandResult): + return resolved + scope, rest = resolved skill_id = ' '.join(rest) try: dest = mgr.remove_imported( @@ -332,7 +366,73 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: content=f'Removed managed skill {skill_id} ({dest}).', ) - return CommandResult(type=CommandResultType.MESSAGE, content=_SKILL_USAGE) + 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 _default_skill_scope(work: str | None) -> str: + return 'project' if work else 'global' + + +def _resolve_skill_scope( + tokens: list[str], + work: str | None, + *, + syntax: str, + ctx: CommandContext, +) -> 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: + scope = _default_skill_scope(work) + 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 _resync_skills(ctx, mgr, home: str, work: str | None) -> None: diff --git a/ms_agent/command/builtin/search_cmds.py b/ms_agent/command/builtin/search_cmds.py index a483d9346..e126cfd2c 100644 --- a/ms_agent/command/builtin/search_cmds.py +++ b/ms_agent/command/builtin/search_cmds.py @@ -4,6 +4,7 @@ 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, same_as_webui, status_then_usage CMD_SEARCH = CommandDef( name='search', @@ -11,17 +12,19 @@ category='config', ) -_USAGE = ( - 'usage:\n' - ' /search\n' - ' /search list\n' - ' /search engine \n' - ' /search key \n' - ' /search key clear\n' - ' /search enable|disable\n' - 'Saved to ~/.ms_agent/settings.json tools.web_search (shared with WebUI). ' - 'Takes effect on the next turn, or /new if search was already connected.' -) + +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'{same_as_webui("settings.json")} ' + 'Takes effect on the next turn, or /new if search was already connected.' + ) def _mgr(): @@ -69,7 +72,7 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: if not arg or arg in ('help', '-h', '--help'): return CommandResult( type=CommandResultType.MESSAGE, - content=_status_text(mgr) + '\n\n' + _USAGE, + content=status_then_usage(_status_text(mgr), _usage()), ) import shlex @@ -81,7 +84,7 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: rest = parts[1:] if action == 'list': - lines = ['Search engines (settings.json, shared with WebUI):'] + lines = ['Search engines (shared with WebUI):'] current = mgr.get().engine for row in mgr.list_engines(): mark = '*' if row['id'] == current else ' ' @@ -110,8 +113,8 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: if action == 'engine': if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_USAGE) + return arg_error( + '/search engine ', ctx=ctx) try: cur = mgr.set_engine(rest[0]) except ValueError as exc: @@ -125,8 +128,7 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: if action == 'key': if not rest: - return CommandResult( - type=CommandResultType.MESSAGE, content=_USAGE) + return arg_error('/search key |clear', ctx=ctx) raw = ' '.join(rest) clear = raw.lower() in ('clear', 'none', '-') try: @@ -141,7 +143,12 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: content=f'API key {verb} for {mgr.get().engine}. {note}', ) - return CommandResult(type=CommandResultType.MESSAGE, content=_USAGE) + 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: diff --git a/ms_agent/command/interactive.py b/ms_agent/command/interactive.py index ef12bf3c6..22e4ebc83 100644 --- a/ms_agent/command/interactive.py +++ b/ms_agent/command/interactive.py @@ -123,9 +123,17 @@ async def run_turn( extra={ '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) diff --git a/ms_agent/command/scope.py b/ms_agent/command/scope.py new file mode 100644 index 000000000..b6e2d9e5a --- /dev/null +++ b/ms_agent/command/scope.py @@ -0,0 +1,144 @@ +# 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) — labels match the Claude plugin install picker tone. +SCOPE_CHOICES: tuple[tuple[str, str], ...] = ( + ('global', 'This machine (global)'), + ('project', 'This folder (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.', +) -> str | CommandResult: + """Return a scope, or a CommandResult (Need: / Cancelled). + + One remaining choice is taken without asking. Tests may inject + ``extra['choose_scope']`` (sync or async callable) returning an index, + a scope id, or a label. + """ + 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 + 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 + return arg_error(syntax, 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..f158cacc9 --- /dev/null +++ b/ms_agent/command/usage.py @@ -0,0 +1,75 @@ +# 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 same_as_webui(rel: str = '') -> str: + """Shared-ledger line: live path, then the env var that produced it. + + The path is not a secret. Showing only ``MS_AGENT_HOME`` makes a person + expand it themselves; showing only ``~/.ms_agent`` lies when the env is set. + """ + loc = ledger_file(rel) if rel else ledger_dir() + return f'Same as WebUI: {loc} (from MS_AGENT_HOME).' + + +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 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: {got}') + if note: + lines.append(note) + return CommandResult(type=CommandResultType.MESSAGE, content='\n'.join(lines)) diff --git a/ms_agent/config/model_settings.py b/ms_agent/config/model_settings.py index fb74ccff8..fe296e266 100644 --- a/ms_agent/config/model_settings.py +++ b/ms_agent/config/model_settings.py @@ -15,6 +15,21 @@ from typing import Any, Dict, List, Optional +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.""" @@ -149,12 +164,16 @@ def add_model(self, provider_id: str, model: str) -> None: models.append(model) self._save_raw(data) - 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) - self._save_raw(data) + models = (entry or {}).get('models') or [] + if entry is None or model not in models: + return False + models.remove(model) + self._save_raw(data) + return True # -- default model -- @@ -165,6 +184,7 @@ 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 diff --git a/ms_agent/config/resolver.py b/ms_agent/config/resolver.py index fd9130702..9c857cc73 100644 --- a/ms_agent/config/resolver.py +++ b/ms_agent/config/resolver.py @@ -427,7 +427,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: diff --git a/ms_agent/llm/credentials.py b/ms_agent/llm/credentials.py index 76a0856dc..9131e7f15 100644 --- a/ms_agent/llm/credentials.py +++ b/ms_agent/llm/credentials.py @@ -30,6 +30,21 @@ 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 missing_api_key_setup_text(exc: BaseException) -> str: + return ( + f'{exc}\n\n' + 'The session stays open — configure a key, then send a message.\n' + ' /model provider key \n' + ' /model provider add openai key=... url=...\n' + ' /model openai/\n' + ' /quit to leave') + + 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..ce07f6e0b --- /dev/null +++ b/ms_agent/llm/model_discovery.py @@ -0,0 +1,162 @@ +# 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 typing import Iterable, List, 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' + + +def fetch_model_ids(base_url: str, protocol: str, api_key: str) -> List[str]: + """Return available model ids, or [] on any failure.""" + if not base_url: + return [] + base = base_url.rstrip('/') + try: + 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}' + with httpx.Client(timeout=8) as client: + resp = client.get(url, headers=headers) + if resp.status_code // 100 != 2: + return [] + return parse_model_ids(resp.json()) + except Exception: + return [] + + +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/skill/harness.py b/ms_agent/skill/harness.py new file mode 100644 index 000000000..8dd18c093 --- /dev/null +++ b/ms_agent/skill/harness.py @@ -0,0 +1,54 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Live path substitution for bundled harness skills. + +Claude Code keeps settings.json / MCP playbooks out of the always-on system +prompt: the Skill listing is name + a short when-to-use description, and the +full guide (with live paths) is produced only when the skill is invoked. +``update-config`` follows that pattern — placeholders in SKILL.md are filled +here at ``skill_view`` / slash-expand time, not at prompt assembly. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional + +HARNESS_SKILL_IDS = frozenset({'update-config'}) + + +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/schema.py b/ms_agent/skill/schema.py index bcdc0094c..3d8907ed3 100644 --- a/ms_agent/skill/schema.py +++ b/ms_agent/skill/schema.py @@ -270,13 +270,13 @@ def parse_skill_directory(directory_path: 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 = [] @@ -355,8 +355,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/skills/update-config/SKILL.md b/ms_agent/skills/update-config/SKILL.md new file mode 100644 index 000000000..54ebf7c0d --- /dev/null +++ b/ms_agent/skills/update-config/SKILL.md @@ -0,0 +1,88 @@ +--- +name: update-config +description: "Use this skill to configure the ms-agent harness: long-term memory (MEMORY.md, /memory on), MCP servers (mcp.json, /mcp add), and global vs project scope. Trigger on 配置记忆, 添加 MCP, 怎么开 memory, turn memory on, add an MCP server, or any ask about where config lives. Do not put memory settings in config.yaml — the project memory flag is what injects it. Not for writing the user's application code." +--- + +# Update Config + +Configure **ms-agent itself** (memory, MCP, global vs project). This is the +harness, not the user's application. + +Live paths on this machine are already filled in below. Read the target file +before writing. Merge; never replace a whole config file. + +## Two scopes + +| What | This folder (project) | Every project (global) | +|------|------------------------|------------------------| +| Long-term memory file | `{memory_md}` | Memory is per-project; there is no global MEMORY.md | +| Memory **flag** (what actually injects it) | TUI `/memory on`, or the WebUI project memory toggle | `/memory global on` only sets the default for **newly opened** folders | +| MCP servers | `{project_mcp}` | `{global_mcp}` (WebUI Settings → MCP) | + +A server in the global file is not the same as one in the project file. Ask +if scope is unclear. + +## Long-term memory + +1. Facts live in `{memory_md}`. +2. Creating that file does **nothing** until the **project** memory flag is + on. TUI: `/memory on` then `/new`. WebUI: the project memory toggle. +3. **Do not** add `memory.unified_memory` (or any memory block) to + `{work}/.ms_agent/config.yaml` or to agent.yaml. That is not how this + product enables memory. + +Once the flag is on, prefer the session's memory tools for new facts; edit +MEMORY.md directly only when the user asks to inspect or rewrite it. + +## MCP servers + +TUI slash commands write the same files WebUI uses: + +- This folder: `/mcp add NAME project url=...` (omitting scope writes project) +- Every project: `/mcp add NAME global url=...` + +If you edit the file yourself, read it first and merge into `mcpServers`. + +HTTP: + +```json +{ + "mcpServers": { + "NAME": { + "type": "streamable_http", + "url": "https://example.com/mcp" + } + } +} +``` + +stdio: + +```json +{ + "mcpServers": { + "NAME": { + "command": "npx", + "args": ["-y", "some-mcp-server"] + } + } +} +``` + +New servers connect this session when possible; otherwise tell the user +`/new` or restart. + +## Workflow + +1. Clarify global vs project if ambiguous. +2. Read the existing file (it may not exist yet — that is fine). +3. Merge the change; keep unrelated keys. +4. For memory, also tell the user the **project flag** must be on. +5. Confirm the path you wrote and the scope. + +## Common mistakes + +1. Teaching `config.yaml` / `memory.unified_memory` as the way to turn memory on. +2. Writing MEMORY.md without enabling the project flag. +3. Putting a project MCP server in `{global_mcp}` (or the reverse). +4. Replacing `mcp.json` instead of merging `mcpServers`. diff --git a/ms_agent/tools/code/local_code_executor.py b/ms_agent/tools/code/local_code_executor.py index c1490742d..cc486f44d 100644 --- a/ms_agent/tools/code/local_code_executor.py +++ b/ms_agent/tools/code/local_code_executor.py @@ -173,18 +173,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: @@ -464,7 +468,10 @@ async def cleanup(self) -> None: self._watcher_tasks.clear() if not self._initialized: return - 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]: 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..06f81892d 100644 --- a/ms_agent/tools/mcp_client.py +++ b/ms_agent/tools/mcp_client.py @@ -23,6 +23,17 @@ logger = get_logger() + +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' @@ -500,7 +511,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) @@ -555,8 +570,15 @@ async def _stop_server(self, await asyncio.wait_for( asyncio.shield(task), timeout=SERVER_STOP_TIMEOUT) return - except (asyncio.TimeoutError, asyncio.CancelledError): - pass + except asyncio.TimeoutError: + logger.debug('MCP server %s stop timed out', server_name) + 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) + return except BaseException as exc: # noqa: BLE001 logger.debug('MCP server %s stopped with %s', server_name, exc) return @@ -678,8 +700,13 @@ async def add_mcp_config(self, mcp_config: Dict[str, Dict[str, Any]]): 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: + await self.disconnect_server(name) + except (asyncio.CancelledError, Exception): # noqa: BLE001 + logger.debug('MCP disconnect %s during cleanup', name, + exc_info=True) + with suppress(BaseException): + await self.exit_stack.aclose() async def __aenter__(self) -> 'MCPClient': try: diff --git a/ms_agent/tools/tool_manager.py b/ms_agent/tools/tool_manager.py index 5d5473fb7..600dc9376 100644 --- a/ms_agent/tools/tool_manager.py +++ b/ms_agent/tools/tool_manager.py @@ -372,13 +372,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: diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index 191a3f857..e9bf08fa0 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -27,6 +27,7 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table +from types import SimpleNamespace from typing import Optional, Tuple from ms_agent.config import Config @@ -42,13 +43,21 @@ # 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, }, }, + # Non-empty so LLMAgent.prepare_skills runs and loads bundled skills + # (update-config, …). An empty ``{}`` is falsy under OmegaConf. + 'skills': { + 'prompt_injection': 'all', + }, } @@ -151,6 +160,8 @@ 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() @@ -168,10 +179,8 @@ def _apply_provider_credentials(config, overwrite: bool = False) -> None: raw = ModelSettingsManager(global_home())._load_raw() except Exception: return - service = str(OmegaConf.select(config, 'llm.service', default='') or '') - entry = (raw.get('providers') or {}).get(service) or {} - if not service or not isinstance(entry, dict): - return + if not isinstance(raw, dict): + raw = {} def _set(field, value, *, force: bool) -> None: if value in (None, ''): @@ -181,6 +190,27 @@ def _set(field, value, *, force: bool) -> None: 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) @@ -294,8 +324,9 @@ def _prepare_config(config, permission_mode, work_dir, project=None): if c != 'input_callback' ] OmegaConf.update(config, 'callbacks', cbs, merge=False) - # Merge the work-dir project patch (e.g. a persisted /model override). - # Skipped when ConfigResolver.resolve() already applied it. + # 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 @@ -585,6 +616,68 @@ def _quiet_logs() -> None: for h in lg.handlers: h.setLevel(logging.ERROR) + @staticmethod + def _is_missing_api_key(exc: BaseException) -> bool: + from ms_agent.llm.credentials import is_missing_api_key_error + return is_missing_api_key_error(exc) + + @staticmethod + def _credential_setup_text(exc: BaseException) -> str: + from ms_agent.llm.credentials import missing_api_key_setup_text + return missing_api_key_setup_text(exc) + + def _ensure_command_runtime(self) -> None: + """Let slash commands run after prepare_llm failed (no live LLM yet).""" + from ms_agent.agent.runtime import Runtime + cfg = self.agent.config + llm = getattr(self.agent, 'llm', None) + if llm is None: + llm = SimpleNamespace( + config=cfg, + model=str( + OmegaConf.select(cfg, 'llm.model', default='') or ''), + _setup_stub=True, + ) + self.agent.llm = llm + if getattr(self.agent, 'runtime', None) is None: + self.agent.runtime = Runtime(llm=llm) + elif getattr(self.agent.runtime, 'llm', None) is None: + self.agent.runtime.llm = llm + + async def _setup_until_ready(self) -> Optional[str]: + """Prompt until credentials work or the user quits. Returns first chat.""" + from ms_agent.command.interactive import InteractiveSession + from ms_agent.llm import LLM + + self._ensure_command_runtime() + session = InteractiveSession( + self.router, + source='tui', + input_source=self.input, + event_sink=self.renderer, + ) + while True: + turn = await session.run_turn( + messages=None, runtime=self.agent.runtime) + if turn.action == 'quit': + return None + try: + rebuilt = LLM.from_config(self.agent.config) + except ValueError as exc: + if self._is_missing_api_key(exc): + self.console.print( + Panel( + self._credential_setup_text(exc), + title='[yellow]still no API key[/]', + border_style='yellow', + expand=False)) + continue + raise + self.agent.llm = rebuilt + if self.agent.runtime is not None: + self.agent.runtime.llm = rebuilt + return turn.text or '' + # -- main loop (route A: one lifecycle per session) -- async def _serve(self) -> None: @@ -600,11 +693,15 @@ async def _serve(self) -> None: 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: + break # generator/teardown cancel on /quit — not a crash except KeyboardInterrupt: # Ctrl-C interrupts the running turn but keeps the REPL alive. # Cap the turn with an assistant marker so the resume below @@ -623,16 +720,35 @@ async def _serve(self) -> None: resume = True continue except Exception as e: # noqa: BLE001 — surface, don't crash the REPL + if isinstance(e, RuntimeError) and ( + 'cancel scope' in str(e) or 'athrow()' in str(e)): + break self.renderer.finalize() - logger.warning('TUI lifecycle error', exc_info=True) + if self._is_missing_api_key(e): + logger.info('TUI waiting for API key: %s', e) + self.console.print( + Panel( + self._credential_setup_text(e), + title='[yellow]setup[/]', + border_style='yellow', + expand=False)) + queued = await self._setup_until_ready() + if queued is None: + break + self._queued_query = queued + resume = False + continue + 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. 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 diff --git a/setup.py b/setup.py index e75605cf8..1202b0410 100644 --- a/setup.py +++ b/setup.py @@ -236,6 +236,7 @@ def run(self): # get_defaults() returns {} and cross-framework convert # silently degrades to a raw file copy. 'agent_hub/default_configs/**/*', + 'skills/**/*', 'webui/**/*', ], '': ['*.h', '*.cpp', '*.cu'], diff --git a/tests/agent_hub/test_convert_targetname.py b/tests/agent_hub/test_convert_targetname.py index a1b7d3142..e27c3c586 100644 --- a/tests/agent_hub/test_convert_targetname.py +++ b/tests/agent_hub/test_convert_targetname.py @@ -13,6 +13,7 @@ Usage: python -m pytest tests/agent/test_convert_targetname.py -v """ +import os import tempfile import unittest from pathlib import Path @@ -465,17 +466,27 @@ class TestFourFrameworkConvertMatrix(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.base = Path(self.tmp.name) + self._old_home = os.environ.get("MS_AGENT_HOME") + os.environ["MS_AGENT_HOME"] = str(self.base / "ms_home") def tearDown(self): + if self._old_home is None: + os.environ.pop("MS_AGENT_HOME", None) + else: + os.environ["MS_AGENT_HOME"] = self._old_home self.tmp.cleanup() - def _convert(self, src_files, source_fw, target_fw): + def _convert(self, src_files, source_fw, target_fw, *, work_dir=None): src = self.base / f"{source_fw}_src" out = self.base / f"{source_fw}_to_{target_fw}" _write(build_spec(source_fw, "bot-a", str(src)).workspace_root, src_files) + extra = {} + if target_fw == "ms-agent": + extra["work_dir"] = str(work_dir or (self.base / "work")) rc = cmd_convert( source_fw=source_fw, target_fw=target_fw, from_name="bot-a", local_dir=str(src), out_dir=str(out), + **extra, ) self.assertEqual(rc, 0, f"{source_fw}->{target_fw} convert failed") return _read_all(build_spec(target_fw, "bot-a", str(out)).workspace_root) @@ -541,25 +552,81 @@ def test_hermes_to_qwenpaw_identity_survives(self): self.assertIn("memory/USER.md", files) self.assertIn("HM_USER_MARKER", files["memory/USER.md"]) - def test_openclaw_to_ms_agent_memory_folds_into_agents(self): - """openclaw -> ms-agent: ms-agent has no memory slot (memory is - project-level at runtime), so MEMORY.md content is folded into the - catch-all AGENTS.md rather than written as a dead global file.""" + def test_openclaw_to_ms_agent_memory_lands_in_work_dir(self): + """openclaw -> ms-agent: MEMORY.md lands in the project work dir + (``/.ms_agent/memory/MEMORY.md``), not folded into AGENTS.md + and not written as a dead global-home file.""" + work = self.base / "work" files = self._convert( { "SOUL.md": "# Soul\nOC soul.\n", "MEMORY.md": "# Memory\nOC_MEM_MARKER.\n", }, "openclaw", "ms-agent", + work_dir=work, ) - # no standalone memory file in the ms-agent global layout. self.assertNotIn("MEMORY.md", files) - # content is preserved by folding into the catch-all instructions file. - self.assertIn("AGENTS.md", files) - self.assertIn("OC_MEM_MARKER", files["AGENTS.md"]) - # single-agent target: no agent-prefixed dirs. + agents = files.get("AGENTS.md", "") + self.assertNotIn("OC_MEM_MARKER", agents) + mem = work / ".ms_agent" / "memory" / "MEMORY.md" + self.assertTrue(mem.is_file(), f"missing project memory at {mem}") + self.assertIn("OC_MEM_MARKER", mem.read_text(encoding="utf-8")) + from ms_agent.project.manager import ProjectManager + proj = ProjectManager( + base_dir=os.environ["MS_AGENT_HOME"]).find_by_path(str(work)) + self.assertIsNotNone(proj) + self.assertTrue(proj.memory_enabled) + self.assertEqual(proj.memory_backend or "file", "file") self.assertFalse(any("bot-a" in p for p in files)) + def test_openclaw_to_ms_agent_does_not_switch_vector_backend(self): + """Convert still writes MEMORY.md, but must not flip an existing + vector-backend project onto file memory.""" + from ms_agent.project.manager import ProjectManager + work = self.base / "vector-work" + work.mkdir() + pm = ProjectManager(base_dir=os.environ["MS_AGENT_HOME"]) + proj = pm.open_folder(str(work)) + pm.update(proj.id, memory_enabled=True, memory_backend="vector") + self._convert( + { + "SOUL.md": "# Soul\nOC soul.\n", + "MEMORY.md": "# Memory\nKEEP_VECTOR.\n", + }, + "openclaw", "ms-agent", + work_dir=work, + ) + mem = work / ".ms_agent" / "memory" / "MEMORY.md" + self.assertTrue(mem.is_file()) + self.assertIn("KEEP_VECTOR", mem.read_text(encoding="utf-8")) + updated = pm.find_by_path(str(work)) + self.assertEqual(updated.memory_backend, "vector") + self.assertTrue(updated.memory_enabled) + + def test_ms_agent_work_dir_defaults_to_cwd(self): + """Omitted --work-dir writes MEMORY.md under the current directory.""" + cwd = self.base / "cwd-work" + cwd.mkdir() + src = self.base / "openclaw_cwd_src" + _write(build_spec("openclaw", "bot-a", str(src)).workspace_root, { + "SOUL.md": "# Soul\n", + "MEMORY.md": "# Memory\nCWD_MEM_MARKER.\n", + }) + out = self.base / "cwd-out" + old = os.getcwd() + os.chdir(cwd) + try: + rc = cmd_convert( + source_fw="openclaw", target_fw="ms-agent", + from_name="bot-a", local_dir=str(src), out_dir=str(out), + ) + finally: + os.chdir(old) + self.assertEqual(rc, 0) + mem = cwd / ".ms_agent" / "memory" / "MEMORY.md" + self.assertTrue(mem.is_file()) + self.assertIn("CWD_MEM_MARKER", mem.read_text(encoding="utf-8")) + class TestQoderPersonaOutbound(unittest.TestCase): """Converting OUT of qoder must not lose the per-agent persona file. diff --git a/tests/agent_hub/test_merge.py b/tests/agent_hub/test_merge.py index dcce31893..c77c5ddee 100644 --- a/tests/agent_hub/test_merge.py +++ b/tests/agent_hub/test_merge.py @@ -276,10 +276,9 @@ def test_cross_product_memory_md(self): self.assertEqual( _resolve_target_path("hermes", "memories/MEMORY.md", "qoder"), "memory/MEMORY.md") - # ms-agent has no memory slot, so qoder memory has no semantic target - # there either (folds into the catch-all instead). - self.assertIsNone( - _resolve_target_path("qoder", "memory/MEMORY.md", "ms-agent")) + self.assertEqual( + _resolve_target_path("qoder", "memory/MEMORY.md", "ms-agent"), + "memory/MEMORY.md") def test_cross_product_ms_agent_profile(self): # ms-agent PROFILE.md -> qwenpaw maps to memory/USER.md (USER group). @@ -305,13 +304,17 @@ def test_cross_product_qoder_user_md(self): self.assertIsNone( _resolve_target_path("qoder", "memory/USER.md", "openhuman")) - def test_cross_product_ms_agent_no_memory_slot(self): - # ms-agent has NO memory slot (memory is project-level at runtime, not - # part of the global home layout). An inbound MEMORY.md therefore has no - # semantic target and returns None, letting the merger fold it into the - # catch-all instructions file instead of writing a dead MEMORY.md. - self.assertIsNone(_resolve_target_path("openclaw", "MEMORY.md", "ms-agent")) - self.assertIsNone(_resolve_target_path("nanobot", "memory/MEMORY.md", "ms-agent")) + def test_cross_product_ms_agent_memory_slot(self): + # ms-agent runtime memory is project-level. The merger still maps + # inbound MEMORY.md onto the semantic slot ``memory/MEMORY.md``; + # convert_workspace peels that file out of the global home and writes + # it under ``/.ms_agent/memory/MEMORY.md``. + self.assertEqual( + _resolve_target_path("openclaw", "MEMORY.md", "ms-agent"), + "memory/MEMORY.md") + self.assertEqual( + _resolve_target_path("nanobot", "memory/MEMORY.md", "ms-agent"), + "memory/MEMORY.md") def test_cross_product_no_mapping_passthrough(self): result = _resolve_target_path("nanobot", "skills/my-skill/SKILL.md", "openclaw") @@ -460,10 +463,10 @@ def test_loose_non_md_memory_not_rehomed(self): self.assertIn("memory/notes.json", result.merged_files) self.assertNotIn("memories/notes.json", result.merged_files) - def test_loose_memory_keeps_path_for_ms_agent(self): - """ms-agent has no home-level memory slot: loose detail keeps its - original path at the merge level and the target-spec filter drops - it (memory stays out of ms-agent by design).""" + def test_loose_memory_inlined_for_ms_agent(self): + """ms-agent runtime reads one MEMORY.md: loose topic detail is + inlined into ``memory/MEMORY.md`` (then convert peels that file + into the project work dir).""" result = merge_resources( incoming={"memory/t.md": "topic body\n"}, source_product="qoder", @@ -471,8 +474,9 @@ def test_loose_memory_keeps_path_for_ms_agent(self): source_defaults={}, target_defaults={}, ) - self.assertEqual(result.merged_files.get("memory/t.md"), - "topic body\n") + merged = result.merged_files["memory/MEMORY.md"] + self.assertIn("topic body", merged) + self.assertNotIn("memory/t.md", result.merged_files) def test_fills_missing_from_target_defaults(self): """merge_resources fills target defaults for absent source files.""" diff --git a/tests/agent_hub/test_workspace.py b/tests/agent_hub/test_workspace.py index a27cf6401..fe18adf5d 100644 --- a/tests/agent_hub/test_workspace.py +++ b/tests/agent_hub/test_workspace.py @@ -1,6 +1,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Sub-agent-aware workspace spec collection tests.""" import json +import os import tempfile import unittest from pathlib import Path @@ -512,22 +513,36 @@ def test_convert_end_to_end_picks_live_user_active_profile(self): "GOLD-WEATHER\n") (profile / "skills" / "weather" / "_meta.json").write_text('{"k": 1}') out = Path(self.tmp.name) / "out" - rc = cmd_convert("openhuman", "ms-agent", None, None, - str(self.root), str(out)) - self.assertEqual(rc, 0) - rels = { - str(p.relative_to(out)) for p in out.rglob("*") if p.is_file() - } - self.assertIn("skills/weather/SKILL.md", rels) - self.assertNotIn("skills/weather/_meta.json", rels) - all_text = "".join( - p.read_text(encoding="utf-8") for p in out.rglob("*") - if p.is_file()) - self.assertIn("GOLD-PERSONA", all_text) - self.assertIn("GOLD-MEMORY", all_text) - self.assertIn("GOLD-WEATHER", all_text) - # the stale shell's persona must NOT leak into the output - self.assertNotIn("stale soul", all_text) + work = Path(self.tmp.name) / "work" + work.mkdir() + old_home = os.environ.get("MS_AGENT_HOME") + os.environ["MS_AGENT_HOME"] = str(Path(self.tmp.name) / "ms_home") + try: + rc = cmd_convert( + "openhuman", "ms-agent", None, None, + str(self.root), str(out), work_dir=str(work)) + self.assertEqual(rc, 0) + rels = { + str(p.relative_to(out)) for p in out.rglob("*") if p.is_file() + } + self.assertIn("skills/weather/SKILL.md", rels) + self.assertNotIn("skills/weather/_meta.json", rels) + all_text = "".join( + p.read_text(encoding="utf-8") for p in out.rglob("*") + if p.is_file()) + self.assertIn("GOLD-PERSONA", all_text) + self.assertIn("GOLD-WEATHER", all_text) + self.assertNotIn("GOLD-MEMORY", all_text) + mem = work / ".ms_agent" / "memory" / "MEMORY.md" + self.assertTrue(mem.is_file()) + self.assertIn("GOLD-MEMORY", mem.read_text(encoding="utf-8")) + # the stale shell's persona must NOT leak into the output + self.assertNotIn("stale soul", all_text) + finally: + if old_home is None: + os.environ.pop("MS_AGENT_HOME", None) + else: + os.environ["MS_AGENT_HOME"] = old_home class TestOpenhumanActiveProfile(unittest.TestCase): diff --git a/tests/command/test_interactive_input.py b/tests/command/test_interactive_input.py index 334670362..2ccd64c3e 100644 --- a/tests/command/test_interactive_input.py +++ b/tests/command/test_interactive_input.py @@ -122,6 +122,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 +225,64 @@ 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') diff --git a/tests/command/test_memory_cmds.py b/tests/command/test_memory_cmds.py index 82fb64330..2683b4566 100644 --- a/tests/command/test_memory_cmds.py +++ b/tests/command/test_memory_cmds.py @@ -78,6 +78,64 @@ async def test_project_toggle_writes_meta_and_config( assert node.storage.backend == 'file' assert runtime.memory_tools == ['loaded'] + @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): diff --git a/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py index eee578427..a3fe42e20 100644 --- a/tests/command/test_new_cmds.py +++ b/tests/command/test_new_cmds.py @@ -140,16 +140,237 @@ async def test_show_current_model(self): @pytest.mark.asyncio async def test_switch_model(self): + from unittest.mock import patch runtime = MockRuntime() router = make_router() - ctx = make_ctx('/model gpt-4o', runtime=runtime) - result = await router.dispatch(ctx) + + class Rebuilt: + def __init__(self): + self.config = runtime.llm.config + self.model = 'gpt-4o' + + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + 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): + from unittest.mock import patch + runtime = MockRuntime() + router = make_router() + + class Rebuilt: + def __init__(self): + self.config = runtime.llm.config + self.model = 'MiniMax/MiniMax-M2.1' + + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + 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): + from unittest.mock import patch + runtime = MockRuntime() + router = make_router() + + class Rebuilt: + def __init__(self): + self.config = runtime.llm.config + self.model = 'MiniMax/MiniMax-M2.1' + self.spec = type('S', (), {'name': 'dashscope'})() + self.transport = type('T', (), {'base_url': 'https://ds'})() + + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + 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): + from unittest.mock import patch + runtime = MockRuntime() + + class Rebuilt: + def __init__(self): + self.config = runtime.llm.config + self.model = 'MiniMax/MiniMax-M2.1' + + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + 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) + 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): + from types import SimpleNamespace + from unittest.mock import patch + + 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) + + class Rebuilt: + def __init__(self): + self.config = config + self.model = 'qwen3.8-flash' + + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + 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): # The committed source YAML must never be mutated by /model. yaml_text = ( 'llm:\n' @@ -163,37 +384,55 @@ 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) - result = await router.dispatch(ctx) + + class Rebuilt: + def __init__(self): + self.config = config + self.model = 'qwen3.7-max' + + from unittest.mock import patch + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE - assert 'settings.json' in result.content - assert 'Also saved project patch' in result.content + assert 'Saved as the default' 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, tmp_path): - # No project patch when output_dir/local_dir are unset; still writes - # the WebUI-shared default_model in settings.json. + # Still writes the WebUI-shared default_model in settings.json. runtime = MockRuntime() router = make_router() ctx = make_ctx('/model gpt-4o', runtime=runtime) - result = await router.dispatch(ctx) + + class Rebuilt: + def __init__(self): + self.config = runtime.llm.config + self.model = 'gpt-4o' + + from unittest.mock import patch + with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): + result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE - assert 'settings.json' in result.content + assert 'Saved as the default' 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' @@ -208,7 +447,68 @@ async def test_model_list_reads_settings(self, tmp_path): ctx = make_ctx('/model list', runtime=MockRuntime()) result = await router.dispatch(ctx) assert 'Default: acme/a-1' in result.content - assert 'settings.json' in result.content + assert 'same as WebUI' 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) + 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 2' 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): @@ -219,6 +519,7 @@ async def test_provider_add_set_key_catalog(self, tmp_path): '/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' @@ -240,6 +541,115 @@ async def test_provider_add_set_key_catalog(self, tmp_path): 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 'Same as WebUI:' in result.content + assert 'settings.json' in result.content + assert 'MS_AGENT_HOME' 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( diff --git a/tests/command/test_resource_cmds.py b/tests/command/test_resource_cmds.py index a23452417..597347fd2 100644 --- a/tests/command/test_resource_cmds.py +++ b/tests/command/test_resource_cmds.py @@ -17,16 +17,19 @@ def make_router(): return router -def make_ctx(text, runtime=None): +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={'router': router}, + extra=payload, ) @@ -50,8 +53,23 @@ class TestMcpCommand: 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() @@ -61,6 +79,7 @@ async def test_add_list_disable_global(self, isolate_home): '/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 @@ -90,11 +109,29 @@ async def test_add_project_writes_mcp_json(self, tmp_path, isolate_home): 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 'Settings' 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( @@ -135,6 +172,7 @@ 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 @@ -193,6 +231,92 @@ def reload_all(self): data = SkillsConfigManager(str(isolate_home)).load_global() assert 'demo' in data.get('disabled', []) + @pytest.mark.asyncio + async def test_add_without_scope_uses_project_when_work_dir( + 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 (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_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_enable_without_scope_uses_same_default_as_add( + 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 (project)' in result.content + proj = SkillsConfigManager(str(isolate_home)).load_project(str(work)) + assert 'only-here' in proj.get('disabled', []) + glob = SkillsConfigManager(str(isolate_home)).load_global() + assert 'only-here' not in glob.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_alias_skill_mgr(self): result = await make_router().dispatch(make_ctx('/skill-mgr')) 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 index f9976b23c..bd6f919bb 100644 --- a/tests/command/test_search_cmds.py +++ b/tests/command/test_search_cmds.py @@ -38,10 +38,12 @@ def isolate_home(tmp_path, monkeypatch): class TestSearchCommand: @pytest.mark.asyncio - async def test_default_status(self): + 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 'MS_AGENT_HOME' in result.content @pytest.mark.asyncio async def test_list_marks_current(self): 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 e0e8943ba..6c4983e8f 100644 --- a/tests/config/test_model_settings.py +++ b/tests/config/test_model_settings.py @@ -28,8 +28,11 @@ def test_models_and_default(tmp_path): 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_preserves_other_sections(tmp_path): @@ -62,6 +65,10 @@ def test_resolver_consumes_default_model(): {'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'}) 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_convert_memory.py b/tests/e2e/test_convert_memory.py new file mode 100644 index 000000000..3cc47e60d --- /dev/null +++ b/tests/e2e/test_convert_memory.py @@ -0,0 +1,103 @@ +"""Convert-to-ms-agent project memory landing (TUI / WebUI same file). + +Inbound MEMORY.md must land at ``/.ms_agent/memory/MEMORY.md`` — the +path both TUI (``apply_project_memory`` + FileBasedBackend) and WebUI +(``GET /api/projects/{id}/memory/doc``) read. Disk is the source of truth. +""" +from __future__ import annotations + +from pathlib import Path + +from omegaconf import OmegaConf + +from ms_agent.agent_hub._commands import build_spec, cmd_convert +from ms_agent.personalization.memory_apply import apply_project_memory +from ms_agent.project.manager import ProjectManager +from ms_agent.project.paths import memory_dir +from ms_agent.tui.app import TuiApp + +from tests.e2e.helpers import runtime_for + +MARKER = 'E2E_CONVERT_MEM_MARKER' + + +def _convert_openclaw_memory(work: Path, home_files: Path) -> int: + src = work.parent / 'openclaw_src' + src.mkdir(exist_ok=True) + root = build_spec('openclaw', 'default', str(src)).workspace_root + root.mkdir(parents=True, exist_ok=True) + (root / 'SOUL.md').write_text('# Soul\nconverted persona.\n', encoding='utf-8') + (root / 'MEMORY.md').write_text( + f'# Memory\n{MARKER}\n', encoding='utf-8') + return cmd_convert( + source_fw='openclaw', + target_fw='ms-agent', + from_name='default', + local_dir=str(src), + out_dir=str(home_files), + work_dir=str(work), + ) + + +class TestConvertMemoryLandsInOpenedWork: + def test_file_on_disk_and_project_memory_enabled( + self, isolated_home, work_dir): + rc = _convert_openclaw_memory(work_dir, work_dir.parent / 'ms_home_files') + assert rc == 0 + mem = memory_dir(work_dir) / 'MEMORY.md' + assert mem.is_file() + assert MARKER in mem.read_text(encoding='utf-8') + pm = ProjectManager(base_dir=str(isolated_home)) + proj = pm.find_by_path(str(work_dir)) + assert proj is not None + assert proj.memory_enabled is True + assert (proj.memory_backend or 'file') == 'file' + + def test_webui_adapter_and_http_read_the_same_file( + self, isolated_home, work_dir, webui, webui_client): + rc = _convert_openclaw_memory(work_dir, work_dir.parent / 'ms_home_files') + assert rc == 0 + pm = ProjectManager(base_dir=str(isolated_home)) + proj = pm.find_by_path(str(work_dir)) + assert proj is not None + + from app.backends.ms_agent import memory as webui_memory + doc = webui_memory.get_doc(proj.id) + assert MARKER in (doc.content or '') + + listed = webui.projects.get_project(proj.id) + assert listed.memory_enabled is True + + resp = webui_client.get(f'/api/projects/{proj.id}/memory/doc') + assert resp.status_code == 200 + body = resp.json() + assert body['code'] == 0 + assert MARKER in body['data']['content'] + + mem_path = memory_dir(work_dir) / 'MEMORY.md' + assert MARKER in mem_path.read_text(encoding='utf-8') + assert Path(proj.path).resolve() == work_dir.resolve() + + def test_tui_open_points_runtime_at_the_same_file( + self, isolated_home, work_dir): + from ms_agent.memory.unified.config import MemoryConfig + from ms_agent.memory.unified.storage.file_storage import ( + FileMemoryStorage, + ) + + rc = _convert_openclaw_memory(work_dir, work_dir.parent / 'ms_home_files') + assert rc == 0 + tui_proj = TuiApp._open_project(str(work_dir)) + assert tui_proj.memory_enabled is True + rt = runtime_for(work_dir) + kind = apply_project_memory(rt.config, tui_proj) + assert kind == 'file' + node = OmegaConf.select(rt.config, 'memory.unified_memory') + assert node is not None + storage = FileMemoryStorage( + MemoryConfig(base_dir=str(memory_dir(work_dir)))) + assert MARKER in storage.get_content() + pm = ProjectManager(base_dir=str(isolated_home)) + registered = pm.find_by_path(str(work_dir)) + assert registered is not None + assert tui_proj.id == registered.id diff --git a/tests/e2e/test_tui_webui_align.py b/tests/e2e/test_tui_webui_align.py new file mode 100644 index 000000000..1fdd58c7b --- /dev/null +++ b/tests/e2e/test_tui_webui_align.py @@ -0,0 +1,569 @@ +"""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=set' 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 + node = OmegaConf.select(rt.config, 'memory.unified_memory') + assert node is not 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_after_load_asks_for_new(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 '/new' in result.content + + +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..83b83f249 --- /dev/null +++ b/tests/e2e/test_tui_webui_usability.py @@ -0,0 +1,368 @@ +"""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 *and* that MS_AGENT_HOME produced it.""" + 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 'MS_AGENT_HOME' in content, ( + 'help should say the path comes from MS_AGENT_HOME') + + +class TestHelpTextLiesAboutHome: + """Slash help must not hardcode ``~/.ms_agent`` when MS_AGENT_HOME is set. + + Testers are told never to use ~/.ms_agent. Showing only the env var name + also makes them expand it themselves — print the live path, then say + which env produced it. + """ + + @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. + + WebUI Settings → MCP is the global page, so the success line must + mention that. ``/skills add`` and ``/skills enable`` share one default: + project when a work dir is open, otherwise global. + """ + + @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 'global' in lowered and ( + 'settings' in lowered or 'not visible' in lowered + or 'project scope' in lowered) + + @pytest.mark.usability + def test_skills_add_and_enable_share_project_default_when_work_dir( + self, isolated_home, work_dir): + """Same command family, one default: this folder if TUI has --work-dir.""" + 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 '(project)' in added.content + assert (work_dir / '.ms_agent' / 'skills' / 'scoped-skill').is_dir() + assert not (isolated_home / 'skills' / 'scoped-skill').exists() + disabled = run_slash('/skills disable scoped-skill', rt) + assert 'disable scoped-skill (project)' 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 'Saved as the default' 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 TestWebuiKeyMaskLeaksShortSecrets: + """TUI /model list shows key=set|missing. WebUI list must not leak a + short key via first4****last4. Presence is ``set``; long keys may 4+4. + """ + + @pytest.mark.usability + def test_webui_provider_mask_is_not_invertible( + 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)) + 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 masked in ('set', 'configured', '****', '') or masked == '••••' + # A 4+4 mask of a 12-char key is one character away from the secret. + assert not (masked.startswith(secret[:4]) and masked.endswith(secret[-4:])) + + +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_tells_user_session_stays_open(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 '/model provider key' in text + assert '/quit' in text + assert 'stays open' in text.lower() or 'session stays' in text.lower() + + +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/e2e/test_update_config_boot.py b/tests/e2e/test_update_config_boot.py new file mode 100644 index 000000000..4e09b9b25 --- /dev/null +++ b/tests/e2e/test_update_config_boot.py @@ -0,0 +1,227 @@ +"""86119007: update-config skill loads on the real TUI / WebUI boot path. + +Same order as run_loop: prepare_runtime → prepare_tools → prepare_skills. +""" +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +import pytest +from omegaconf import OmegaConf + +from ms_agent.agent.llm_agent import LLMAgent +from ms_agent.command.skill_bridge import expand_skill +from ms_agent.llm.utils import Message, collect_response +from ms_agent.skill.catalog import BUILTIN_SKILLS_DIR +from ms_agent.tui.app import TuiApp +from ms_agent.tui.managed_config import merge_skills_into_config + + +def _boot_agent(cfg) -> LLMAgent: + agent = LLMAgent(config=cfg, tag='e2e-update-config') + agent.prepare_runtime() + + async def _setup(): + await agent.prepare_tools() + await agent.prepare_skills() + + asyncio.run(_setup()) + return agent + + +def _cleanup(agent: LLMAgent) -> None: + asyncio.run(agent.cleanup_tools()) + + +def _tui_agent(work: Path, home: Path) -> LLMAgent: + cfg = TuiApp._load_runtime_config( + 'unused.yaml', str(work), explicit_config=False) + cfg = TuiApp._prepare_config(cfg, None, str(work)) + cfg = merge_skills_into_config(cfg, str(home), str(work)) + return _boot_agent(cfg) + + +def test_builtin_skill_is_on_disk(): + assert (BUILTIN_SKILLS_DIR / 'update-config' / 'SKILL.md').is_file(), ( + f'bundled skill missing at {BUILTIN_SKILLS_DIR}') + + +def test_tui_boot_loads_update_config_into_prompt_and_skill_view( + isolated_home, work_dir): + """Default TUI session: L1 in system prompt, playbook only on skill_view.""" + agent = _tui_agent(work_dir, isolated_home) + try: + assert agent._skill_catalog is not None + skill = agent._skill_catalog.get_skill('update-config') + assert skill is not None + + msgs = asyncio.run(agent.create_messages('帮我把长期记忆打开')) + system = msgs[0].content + assert 'update-config' in system + assert 'skill_view' in system + assert 'streamable_http' not in system + assert 'memory.unified_memory' not in system + + toolset = next( + t for t in (agent.tool_manager.extra_tools or []) + if getattr(t, 'TOOL_SERVER_NAME', None) == 'skills') + viewed = json.loads( + toolset._handle_skill_view({'skill_id': 'update-config'})) + memory_md = str( + (work_dir.resolve() / '.ms_agent' / 'memory' / 'MEMORY.md')) + project_mcp = str(work_dir.resolve() / '.ms_agent' / 'mcp.json') + global_mcp = str(isolated_home / 'mcp.json') + body = viewed['content'] + assert memory_md in body + assert project_mcp in body + assert global_mcp in body + assert '/memory on' in body + assert '/mcp add' in body + assert 'memory.unified_memory' in body + assert '{memory_md}' not in body + finally: + _cleanup(agent) + + +def test_tui_slash_update_config_expands_playbook(isolated_home, work_dir): + agent = _tui_agent(work_dir, isolated_home) + try: + result = expand_skill( + agent._skill_catalog, 'update-config', '打开 memory') + assert result is not None + assert '/memory on' in result.content + assert '打开 memory' in result.content + assert str(isolated_home / 'mcp.json') in result.content + finally: + _cleanup(agent) + + +def test_webui_defaults_also_load_update_config(isolated_home, work_dir): + """WebUI _apply_webui_defaults already opts into skills; builtin must load.""" + from ms_agent.config.resolver import ConfigResolver + + resolver = ConfigResolver( + global_dir=str(isolated_home), project_root=str(work_dir)) + cfg = resolver.resolve(agent_config=None, project_path=str(work_dir)) + if OmegaConf.select(cfg, 'skills', default=None) is None: + OmegaConf.update(cfg, 'skills', {}, merge=True) + OmegaConf.update(cfg, 'skills.prompt_injection', 'all', merge=True) + OmegaConf.update(cfg, 'output_dir', str(work_dir), merge=True) + cfg = merge_skills_into_config(cfg, str(isolated_home), str(work_dir)) + + agent = _boot_agent(cfg) + try: + assert agent._skill_catalog.get_skill('update-config') is not None + system = asyncio.run( + agent.create_messages('add an MCP server'))[0].content + assert 'update-config' in system + assert 'skill_view' in system + finally: + _cleanup(agent) + + +def _tool_name(call) -> str: + if isinstance(call, dict): + return str(call.get('tool_name') or '') + return str(getattr(call, 'tool_name', '') or '') + + +def _tool_args(call) -> dict: + raw = call.get('arguments') if isinstance(call, dict) else getattr( + call, 'arguments', {}) + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return {} + return raw if isinstance(raw, dict) else {} + + +@pytest.mark.live +def test_live_model_views_update_config_to_enable_memory( + isolated_home, work_dir): + """Real DashScope turn: NL 'open memory' must load the skill, not guess config.yaml.""" + 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)) + cfg = merge_skills_into_config(cfg, str(isolated_home), str(work_dir)) + OmegaConf.update(cfg, 'generation_config.stream', False, merge=True) + OmegaConf.update(cfg, 'interactive', False, merge=True) + + agent = _boot_agent(cfg) + try: + agent.prepare_llm() + agent.runtime.llm = agent.llm + query = ( + '帮我把这个项目的长期记忆打开,以后对话都要记住:代码用 ruff。' + '按 ms-agent 真正的配置方式做,不要改 config.yaml。' + '告诉我 MEMORY.md 的绝对路径。') + messages = asyncio.run(agent.create_messages(query)) + tools = asyncio.run(agent.tool_manager.get_tools()) + + viewed = False + viewed_body = '' + calls_log = [] + final_text = '' + for _round in range(4): + reply = collect_response( + agent.llm.generate(messages, tools=tools)) + assert reply is not None + agent.handle_new_response(messages, reply) + names = [_tool_name(c) for c in (reply.tool_calls or [])] + calls_log.append(names) + if not reply.tool_calls: + final_text = reply.content or '' + break + for call in reply.tool_calls: + args = _tool_args(call) + if ('skill_view' in _tool_name(call) + and args.get('skill_id') == 'update-config'): + viewed = True + before = len(messages) + asyncio.run(agent.parallel_tool_call(messages)) + if viewed and not viewed_body: + for msg in messages[before:]: + if getattr(msg, 'role', '') == 'tool': + viewed_body += msg.content or '' + + print('LIVE_TOOL_ROUNDS', calls_log) + print('LIVE_VIEWED', viewed) + print('LIVE_FINAL_HEAD', (final_text or '')[:500]) + assert viewed, ( + f'model never called skill_view(update-config); calls={calls_log} ' + f'final={final_text[:300]!r}') + memory_md = str( + (work_dir.resolve() / '.ms_agent' / 'memory' / 'MEMORY.md')) + assert memory_md in viewed_body + blob = (final_text + viewed_body).lower() + assert '/memory on' in blob or 'memory on' in blob + assert 'unified_memory' not in (final_text or '').lower() or ( + '不要' in (final_text or '') or 'do not' in (final_text or '').lower() + or 'not' in (final_text or '').lower()) + if final_text: + assert memory_md in final_text or '.ms_agent/memory/MEMORY.md' in final_text + finally: + _cleanup(agent) diff --git a/tests/llm/test_model_discovery.py b/tests/llm/test_model_discovery.py new file mode 100644 index 000000000..964168f2b --- /dev/null +++ b/tests/llm/test_model_discovery.py @@ -0,0 +1,129 @@ +# 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_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/prompting/test_workspace_internals_hint.py b/tests/prompting/test_workspace_internals_hint.py index 3c8cb52f7..2893856ed 100644 --- a/tests/prompting/test_workspace_internals_hint.py +++ b/tests/prompting/test_workspace_internals_hint.py @@ -108,3 +108,26 @@ def test_it_reaches_the_system_prompt(tmp_path): content = agent._build_system_content() assert content.startswith('BASE PROMPT') assert 'sessions/sid/' in content + + +def test_system_prompt_does_not_dump_config_playbook(tmp_path, monkeypatch): + """86119007: architecture belongs in the update-config skill, not the + always-on system prompt (Claude Code SkillTool pattern).""" + 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/skills/test_update_config.py b/tests/skills/test_update_config.py new file mode 100644 index 000000000..b58978fa9 --- /dev/null +++ b/tests/skills/test_update_config.py @@ -0,0 +1,129 @@ +"""Bundled update-config skill — Claude Code progressive-disclosure analog. + +L1: catalog listing (name + description) in the skill section. +L2: skill_view / /update-config expands the playbook with live paths. +The always-on system prompt must not contain the playbook. +""" +from __future__ import annotations + +import json + +from omegaconf import OmegaConf + +from ms_agent.command.skill_bridge import expand_skill +from ms_agent.skill.catalog import BUILTIN_SKILLS_DIR, SkillCatalog +from ms_agent.skill.prompt_injector import SkillPromptInjector +from ms_agent.skill.skill_tools import SkillToolSet + + +def _catalog(): + catalog = SkillCatalog(config=OmegaConf.create({})) + catalog.load_from_config(OmegaConf.create({})) + return catalog + + +def test_builtin_dir_contains_update_config(): + skill_md = BUILTIN_SKILLS_DIR / 'update-config' / 'SKILL.md' + assert skill_md.is_file() + + +def test_catalog_lists_update_config_in_l1_summary(): + catalog = _catalog() + skill = catalog.get_skill('update-config') + assert skill is not None + summary = catalog.get_skills_summary() + assert 'update-config' in summary + assert 'MEMORY.md' in skill.description or '/memory' in skill.description + assert 'mcp.json' in skill.description or '/mcp' in skill.description + # L1 is discovery-only: live paths stay out of the listing. + assert '{memory_md}' not in summary + assert '.ms_agent/memory/MEMORY.md' not in summary + + +def test_l1_prompt_section_does_not_include_playbook(): + injector = SkillPromptInjector(_catalog()) + section = injector.build_skill_prompt_section() + assert 'Available Skills' in section + assert 'update-config' in section + assert 'skill_view' in section + # Playbook body stays out of L1 (description may mention /memory as a trigger). + assert '## Two scopes' not in section + assert 'memory.unified_memory' not in section + assert 'streamable_http' not in section + + +def test_skill_view_fills_live_paths(tmp_path, monkeypatch): + home = tmp_path / 'ms_home' + home.mkdir() + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + work = tmp_path / 'proj' + work.mkdir() + cfg = OmegaConf.create({'output_dir': str(work)}) + catalog = _catalog() + toolset = SkillToolSet(cfg, catalog, enable_manage=False) + data = json.loads(toolset._handle_skill_view({'skill_id': 'update-config'})) + + memory_md = str((work / '.ms_agent' / 'memory' / 'MEMORY.md').resolve()) + project_mcp = str(work / '.ms_agent' / 'mcp.json') + global_mcp = str(home / 'mcp.json') + assert memory_md in data['content'] + assert project_mcp in data['content'] + assert global_mcp in data['content'] + assert '{memory_md}' not in data['content'] + assert '{home}' not in data['content'] + assert '/memory on' in data['content'] + assert '/mcp add' in data['content'] + assert 'config.yaml' in data['content'] + assert 'memory.unified_memory' in data['content'] + assert 'Do not' in data['content'] + + +def test_slash_expand_fills_live_paths(tmp_path, monkeypatch): + home = tmp_path / 'ms_home' + home.mkdir() + monkeypatch.setenv('MS_AGENT_HOME', str(home)) + catalog = _catalog() + result = expand_skill(catalog, 'update-config', 'turn memory on') + assert result is not None + assert '/memory on' in result.content + assert str(home / 'mcp.json') in result.content + assert 'turn memory on' in result.content + assert '{memory_md}' not in result.content + + +def test_other_skills_are_not_rewritten(tmp_path): + from ms_agent.skill.harness import fill_harness_placeholders + 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_enable_prepare_skills(tmp_path, monkeypatch): + """Default TUI (no --config) must load bundled skills.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock + + from ms_agent.agent.llm_agent import LLMAgent + from ms_agent.tui.app import TuiApp + + 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 not None + content = agent._build_system_content() + assert 'update-config' in content + assert 'skill_view' in content + assert 'streamable_http' not in content + assert 'memory.unified_memory' not in content diff --git a/tests/tools/test_mcp_teardown.py b/tests/tools/test_mcp_teardown.py new file mode 100644 index 000000000..bbd8eec64 --- /dev/null +++ b/tests/tools/test_mcp_teardown.py @@ -0,0 +1,110 @@ +"""86121430: MCP streamable_http / jupyter teardown must not dump stack on quit.""" +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from ms_agent.tools.mcp_client import MCPClient, _is_teardown_noise + + +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_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 diff --git a/tests/tui/test_tui_config_align.py b/tests/tui/test_tui_config_align.py index 79c917ecc..db750ae77 100644 --- a/tests/tui/test_tui_config_align.py +++ b/tests/tui/test_tui_config_align.py @@ -13,11 +13,14 @@ 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 @@ -105,6 +108,39 @@ async def _connect(): asyncio.run(_connect()) +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)) @@ -266,3 +302,89 @@ def test_fill_provider_catalog_does_not_clobber_llm_keys(): 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 TuiApp._is_missing_api_key(err) + assert is_missing_api_key_error(err) + assert not TuiApp._is_missing_api_key(ValueError('boom')) + assert not is_missing_api_key_error(RuntimeError('No API key found')) + text = missing_api_key_setup_text(err) + assert '/model provider key' in text + assert '/quit' in text + + +@pytest.mark.asyncio +async def test_setup_until_ready_quit_returns_none(): + from ms_agent.command import CommandRouter, register_builtin_commands + from ms_agent.agent.runtime import Runtime + + app = TuiApp.__new__(TuiApp) + cfg = OmegaConf.create({'llm': {'model': 'm', 'service': 'modelscope'}}) + router = CommandRouter() + register_builtin_commands(router) + app.agent = SimpleNamespace( + config=cfg, llm=None, runtime=Runtime(llm=None)) + app.router = router + app.input = None + app.renderer = None + app.console = Console(file=io.StringIO()) + + with patch('builtins.input', return_value='/quit'): + queued = await app._setup_until_ready() + assert queued is None + + +@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._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) + + 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 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 5c8330954..47e1b696e 100644 --- a/tests/utils/test_snapshot_smoke.py +++ b/tests/utils/test_snapshot_smoke.py @@ -341,8 +341,8 @@ def test_rollback_invalid_hash_returns_false(self): self.assertFalse(ok) self.assertIsNone(truncated) - def test_on_task_begin_auto_snapshots(self): - """on_task_begin should take a snapshot automatically — no explicit call needed.""" + def test_on_task_begin_skips_snapshot_by_default(self): + """No CLI/TUI rollback UI — do not git-add the work tree unless opted in.""" import asyncio from ms_agent.llm.utils import Message @@ -355,9 +355,29 @@ 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)) + self.assertEqual(list_snapshots(td), []) + 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/agent_settings.py b/webui/backend/app/backends/ms_agent/agent_settings.py index b546e139f..53e2b4af7 100644 --- a/webui/backend/app/backends/ms_agent/agent_settings.py +++ b/webui/backend/app/backends/ms_agent/agent_settings.py @@ -56,25 +56,44 @@ def update_settings(body: AgentSettings) -> AgentSettings: ps = _ps() cur = ps.load() + memory_enabled = ( + cur.memory_enabled if body.default_memory_enabled is None + else body.default_memory_enabled) + memory_backend = ( + cur.memory_backend if body.default_memory_backend is None + else body.default_memory_backend) ps.save( PersonalizationConfig( global_instruction=cur.global_instruction, # preserve - memory_enabled=body.default_memory_enabled, - memory_backend=body.default_memory_backend, + memory_enabled=memory_enabled, + memory_backend=memory_backend, ) ) - sidecar.put("agent_settings", "global_mcp_auto_attach", body.global_mcp_auto_attach) - sidecar.put("agent_settings", "global_skill_auto_attach", body.global_skill_auto_attach) - sidecar.put( - "agent_settings", - "memory_models", - { - "llm_provider_id": body.memory_llm_provider_id, - "llm_model": body.memory_llm_model, - "embed_mode": body.memory_embed_mode, - "embed_provider_id": body.memory_embed_provider_id, - "embed_model": body.memory_embed_model, - "recall_top_k": body.memory_recall_top_k, - }, + if body.global_mcp_auto_attach is not None: + sidecar.put("agent_settings", "global_mcp_auto_attach", body.global_mcp_auto_attach) + if body.global_skill_auto_attach is not None: + sidecar.put("agent_settings", "global_skill_auto_attach", body.global_skill_auto_attach) + mem_fields = ( + body.memory_llm_provider_id, + body.memory_llm_model, + body.memory_embed_mode, + body.memory_embed_provider_id, + body.memory_embed_model, + body.memory_recall_top_k, ) + if any(v is not None for v in mem_fields): + mem_cfg = sidecar.get("agent_settings", "memory_models", {}) or {} + if body.memory_llm_provider_id is not None: + mem_cfg["llm_provider_id"] = body.memory_llm_provider_id + if body.memory_llm_model is not None: + mem_cfg["llm_model"] = body.memory_llm_model + if body.memory_embed_mode is not None: + mem_cfg["embed_mode"] = body.memory_embed_mode + if body.memory_embed_provider_id is not None: + mem_cfg["embed_provider_id"] = body.memory_embed_provider_id + if body.memory_embed_model is not None: + mem_cfg["embed_model"] = body.memory_embed_model + if body.memory_recall_top_k is not None: + mem_cfg["recall_top_k"] = body.memory_recall_top_k + sidecar.put("agent_settings", "memory_models", mem_cfg) return get_settings() diff --git a/webui/backend/app/backends/ms_agent/mapping.py b/webui/backend/app/backends/ms_agent/mapping.py index 82e3ec3d9..b9d2f0545 100644 --- a/webui/backend/app/backends/ms_agent/mapping.py +++ b/webui/backend/app/backends/ms_agent/mapping.py @@ -40,10 +40,15 @@ def _generation_defaults(protocol: str, provider: str) -> dict: def _mask(api_key: str) -> str: + """List-safe key presence. Short secrets must not leak a 4+4 prefix/suffix. + + Empty → not configured. Under 16 chars → ``set`` (same granularity as TUI + /model list). Longer keys may show first4****last4. + """ if not api_key: return "" - if len(api_key) <= 8: - return "****" + if len(api_key) < 16: + return "set" return f"{api_key[:4]}****{api_key[-4:]}" 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/tests/test_mapping.py b/webui/backend/tests/test_mapping.py index 6b2bb85b1..65137afaf 100644 --- a/webui/backend/tests/test_mapping.py +++ b/webui/backend/tests/test_mapping.py @@ -70,5 +70,6 @@ def test_protocol_and_mask(): assert _protocol("openai_compat") == "openai" assert _protocol("anthropic_messages") == "anthropic" assert _mask("") == "" - assert _mask("short") == "****" + assert _mask("short") == "set" + assert _mask("sk-test-acme") == "set" # 12 chars: no 4+4 leak assert _mask("sk-secret-1234567890") == "sk-s****7890" From 1d56444d67da440fec634a8b5a3de2c9ab9948f6 Mon Sep 17 00:00:00 2001 From: suluyan Date: Wed, 23 Sep 2026 17:54:12 +0800 Subject: [PATCH 03/21] revert: cli Convert with memory --- ms_agent/agent_hub/_commands.py | 129 +-------------------- ms_agent/agent_hub/_merge.py | 12 +- ms_agent/agent_hub/frameworks/ms_agent.py | 2 - ms_agent/cli/agent.py | 11 +- tests/agent_hub/test_convert_targetname.py | 87 ++------------ tests/agent_hub/test_merge.py | 38 +++--- tests/agent_hub/test_workspace.py | 47 +++----- tests/e2e/test_convert_memory.py | 103 ---------------- 8 files changed, 53 insertions(+), 376 deletions(-) delete mode 100644 tests/e2e/test_convert_memory.py diff --git a/ms_agent/agent_hub/_commands.py b/ms_agent/agent_hub/_commands.py index 91fd3f41f..0b5163ed2 100644 --- a/ms_agent/agent_hub/_commands.py +++ b/ms_agent/agent_hub/_commands.py @@ -76,81 +76,6 @@ def _fail(message: str) -> int: return 1 -# Inbound memory for ms-agent is merged onto ``memory/MEMORY.md`` then peeled -# out of the global-home payload. The runtime, TUI and WebUI all read -# ``/.ms_agent/memory/MEMORY.md``. -_MS_AGENT_PROJECT_MEMORY_PREFIX = 'memory/' - - -def peel_ms_agent_project_memory( - files: dict[str, str]) -> tuple[dict[str, str], dict[str, str]]: - """Split ``memory/...`` files out of a convert payload. - - Returns ``(home_files, project_memory_files)``. Project-memory keys are - relative to ``memory_dir(work)`` (so ``memory/MEMORY.md`` becomes - ``MEMORY.md``). - """ - home: dict[str, str] = {} - project: dict[str, str] = {} - prefix = _MS_AGENT_PROJECT_MEMORY_PREFIX - for path, content in files.items(): - if path.startswith(prefix): - project[path[len(prefix):]] = content - else: - home[path] = content - return home, project - - -def _resolve_convert_work_dir(work_dir: str | None) -> Path: - if work_dir: - return Path(work_dir).expanduser().resolve() - return Path.cwd() - - -def _write_ms_agent_project_memory(work: Path, - files: dict[str, str]) -> list[Path]: - from ms_agent.project.paths import memory_dir - dest_root = memory_dir(work) - written: list[Path] = [] - for rel, content in files.items(): - dest = dest_root / rel - dest.parent.mkdir(parents=True, exist_ok=True) - if isinstance(content, bytes): - dest.write_bytes(content) - else: - dest.write_text(content, encoding='utf-8') - written.append(dest) - return written - - -def _enable_ms_agent_project_memory(work: Path) -> None: - """Register *work* as a project and turn on file memory. - - An existing vector-backend project is left alone: writing MEMORY.md must - not silently switch that project off vector. - """ - from ms_agent.project.manager import ProjectManager - from ms_agent.project.paths import global_home - pm = ProjectManager(base_dir=str(global_home())) - proj = pm.open_folder(str(work)) - if getattr(proj, 'memory_backend', None) == 'vector': - return - if proj.memory_enabled: - return - pm.update(proj.id, memory_enabled=True, memory_backend='file') - - -def _print_ms_agent_memory_next_steps(work: Path) -> None: - from ms_agent.project.paths import memory_dir - dest = memory_dir(work) - logger.info( - '项目记忆已写入 %s。请用同一目录打开:' - '`ms-agent tui --work-dir %s`,或在 WebUI 中打开该文件夹。', dest, work) - logger.info( - 'Project memory written to %s. Open this folder in TUI ' - '(`ms-agent tui --work-dir %s`) or WebUI (same path).', dest, work) - - def api_error_message(e: APIError, action: str = 'request') -> str: """Return a user-friendly message based on the HTTP status code.""" status = e.status_code or 0 @@ -1004,14 +929,9 @@ def convert_workspace( target_fw: str, dst_spec: WorkspaceSpec, dry_run: bool = False, - work_dir: str | None = None, ) -> int: """Shared convert logic: merge -> filter defaults -> backup -> write. - ``work_dir`` is the project folder TUI/WebUI will open (default: cwd). - When the target is ms-agent, inbound MEMORY.md is written under - ``/.ms_agent/memory/`` rather than the global home. - Returns 0 on success, 1 on failure. """ src_root = src_spec.workspace_root @@ -1108,12 +1028,6 @@ def convert_workspace( converted = result.merged_files dst_root = dst_spec.workspace_root - work_path = _resolve_convert_work_dir(work_dir) - project_mem: dict[str, str] = {} - if source_fw != target_fw and target_fw == 'ms-agent': - # Peel before the dst-spec filter so ``memory/MEMORY.md`` is not - # dropped as "not part of the global-home workspace". - converted, project_mem = peel_ms_agent_project_memory(converted) # Drop files that don't belong to the target framework's workspace spec. # merge_resources imports unmapped files (e.g. qwenpaw agent.json/skill.json) # as-is; without this filter they would leak into the target framework. @@ -1158,11 +1072,8 @@ def convert_workspace( ) display.meta('source', src_root) display.meta('target', dst_root) - if project_mem: - display.meta('work-dir', work_path) counts = [('in', len(resources), 'bold'), - ('written', - len(effective) + len(project_mem), display.COLOR_WRITTEN)] + ('written', len(effective), display.COLOR_WRITTEN)] if merge_pairs: counts.append(('merged', len(merge_pairs), display.COLOR_MERGED)) if dropped or dropped_memory_payloads: @@ -1171,15 +1082,6 @@ def convert_workspace( display.summary(counts) display.file_list('Written', effective, color=display.COLOR_WRITTEN) - if project_mem: - display.file_list( - 'Project memory', - {f'.ms_agent/memory/{k}': v - for k, v in project_mem.items()}, - color=display.COLOR_WRITTEN, - root=work_path, - note='runtime / TUI / WebUI read this file', - ) display.map_table( 'Merged', merge_pairs, @@ -1223,7 +1125,7 @@ def convert_workspace( print('\n[dry-run] nothing written.') return 0 - if not effective and not project_mem: + if not effective: print('\nNo effective files to write.') return 0 @@ -1234,15 +1136,8 @@ def convert_workspace( f'{target_fw}_{dst_spec.agent_name}') display.meta('backup', backup_path) - if effective: - written = dst_spec.apply(effective) - display.done(f'Wrote {len(written)} file(s) under {dst_root}') - if project_mem: - mem_written = _write_ms_agent_project_memory(work_path, project_mem) - _enable_ms_agent_project_memory(work_path) - display.done(f'Wrote {len(mem_written)} project memory file(s) ' - f'under {work_path / ".ms_agent" / "memory"}') - _print_ms_agent_memory_next_steps(work_path) + written = dst_spec.apply(effective) + display.done(f'Wrote {len(written)} file(s) under {dst_root}') if target_fw == 'openhuman': _print_openhuman_next_steps(dst_root) return 0 @@ -1256,14 +1151,8 @@ def cmd_convert( local_dir=None, out_dir=None, dry_run: bool = False, - work_dir: str | None = None, ) -> int: - """Local-only format conversion: read a workspace, convert, write it out. - - ``work_dir`` (default: cwd) is the project folder that receives ms-agent - MEMORY.md when converting *to* ms-agent. Persona/skills still land in - ``out_dir`` / the global home. - """ + """Local-only format conversion: read a workspace, convert, write it out.""" for fw, label in ((source_fw, '--from-framework'), (target_fw, '--target-framework')): err = check_framework(fw, f'framework for {label}') @@ -1299,13 +1188,7 @@ def cmd_convert( file=sys.stderr, ) return convert_workspace( - src_spec, - source_fw, - target_fw, - dst_spec, - dry_run=dry_run, - work_dir=work_dir, - ) + src_spec, source_fw, target_fw, dst_spec, dry_run=dry_run) def cmd_watch( diff --git a/ms_agent/agent_hub/_merge.py b/ms_agent/agent_hub/_merge.py index 144b54712..03c361a05 100644 --- a/ms_agent/agent_hub/_merge.py +++ b/ms_agent/agent_hub/_merge.py @@ -540,11 +540,7 @@ def _is_private_file(product: str, path: str) -> bool: 'qwenpaw': 'MEMORY.md', 'hermes': 'memories/MEMORY.md', 'openhuman': 'MEMORY.md', - 'qoder': 'memory/MEMORY.md', - # Semantic slot only. convert_workspace peels ``memory/`` out of the - # global-home payload and writes it under - # ``/.ms_agent/memory/MEMORY.md`` (the runtime/WebUI/TUI path). - 'ms-agent': 'memory/MEMORY.md', + 'qoder': 'memory/MEMORY.md' }, { 'openclaw': 'IDENTITY.md', @@ -679,8 +675,8 @@ def _resolve_target_path(source_product: str, source_path: str, # index) land per target. ``None`` = the target reads a single memory file # (:data:`_SINGLE_FILE_MEMORY_SLOTS`) and detail is inlined into it -- a file # the runtime never reads is not a migration. openclaw uses its own -# ``memory/imports//`` convention. ms-agent inlines into -# ``memory/MEMORY.md`` then peels that file to the project work dir. +# ``memory/imports//`` convention; targets without an entry (ms-agent +# has no home-level memory) keep the source path for the spec filter to drop. _MEMORY_LOOSE_HOME = { 'hermes': None, 'openclaw': 'memory/', @@ -688,7 +684,6 @@ def _resolve_target_path(source_product: str, source_path: str, 'qoder': 'memory/', 'openhuman': None, 'nanobot': None, - 'ms-agent': None, } # The single memory file each ``None`` target above actually reads. @@ -696,7 +691,6 @@ def _resolve_target_path(source_product: str, source_path: str, 'nanobot': 'memory/MEMORY.md', 'hermes': 'memories/MEMORY.md', 'openhuman': 'MEMORY.md', - 'ms-agent': 'memory/MEMORY.md', } # openhuman injects MEMORY.md into the system prompt under a char cap; diff --git a/ms_agent/agent_hub/frameworks/ms_agent.py b/ms_agent/agent_hub/frameworks/ms_agent.py index 46762303d..d87d3c163 100644 --- a/ms_agent/agent_hub/frameworks/ms_agent.py +++ b/ms_agent/agent_hub/frameworks/ms_agent.py @@ -38,8 +38,6 @@ class MsAgentWorkspace(WorkspaceSpec): same-framework sync only. Memory is NOT here: the runtime keeps it project-level under ``/.ms_agent/memory/`` (no global memory by design), so the global-home workspace this spec models carries none. - ``convert --target-framework ms-agent --work-dir `` peels inbound - MEMORY.md out of this home and writes it to that project path. Machine bookkeeping never travels: the ``.soul.builtin`` / ``.agents.builtin`` / ``.profile.builtin`` sidecars are dotfiles (skipped diff --git a/ms_agent/cli/agent.py b/ms_agent/cli/agent.py index 553cb13b8..2355dca76 100644 --- a/ms_agent/cli/agent.py +++ b/ms_agent/cli/agent.py @@ -50,7 +50,7 @@ def define_args(parsers: argparse.ArgumentParser): ' status -f FRAMEWORK [--local-dir DIR]\n' ' backups [-f FRAMEWORK] [-n NAME] [--local-dir DIR]\n' ' restore --from-backup TARGET [-f FRAMEWORK] [-n NAME] [--local-dir DIR]\n' - ' convert --from-framework FW --target-framework FW [--from-name NAME] [--target-name NAME] [--local-dir DIR] [--out-dir DIR] [--work-dir DIR] [--dry-run]\n' + ' convert --from-framework FW --target-framework FW [--from-name NAME] [--target-name NAME] [--local-dir DIR] [--out-dir DIR] [--dry-run]\n' ' stop (no arguments)\n' '\n' 'supported frameworks:\n' @@ -342,14 +342,6 @@ def define_args(parsers: argparse.ArgumentParser): help= 'Destination directory to write to (default: target framework path)' ) - p_convert.add_argument( - '--work-dir', - default=None, - help= - 'Project folder TUI/WebUI will open. When converting to ms-agent, ' - 'MEMORY.md is written to /.ms_agent/memory/MEMORY.md ' - '(default: current directory). Ignored for other targets.', - ) p_convert.add_argument( '--dry-run', action='store_true', @@ -458,7 +450,6 @@ def execute(self) -> None: local_dir=args.local_dir, out_dir=args.out_dir, dry_run=args.dry_run, - work_dir=args.work_dir, ) elif action == 'watch': rc = cmd_watch( diff --git a/tests/agent_hub/test_convert_targetname.py b/tests/agent_hub/test_convert_targetname.py index bb541c44d..17340d54f 100644 --- a/tests/agent_hub/test_convert_targetname.py +++ b/tests/agent_hub/test_convert_targetname.py @@ -13,7 +13,6 @@ Usage: python -m pytest tests/agent/test_convert_targetname.py -v """ -import os import tempfile import unittest from pathlib import Path @@ -466,27 +465,17 @@ class TestFourFrameworkConvertMatrix(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.base = Path(self.tmp.name) - self._old_home = os.environ.get("MS_AGENT_HOME") - os.environ["MS_AGENT_HOME"] = str(self.base / "ms_home") def tearDown(self): - if self._old_home is None: - os.environ.pop("MS_AGENT_HOME", None) - else: - os.environ["MS_AGENT_HOME"] = self._old_home self.tmp.cleanup() - def _convert(self, src_files, source_fw, target_fw, *, work_dir=None): + def _convert(self, src_files, source_fw, target_fw): src = self.base / f"{source_fw}_src" out = self.base / f"{source_fw}_to_{target_fw}" _write(build_spec(source_fw, "bot-a", str(src)).workspace_root, src_files) - extra = {} - if target_fw == "ms-agent": - extra["work_dir"] = str(work_dir or (self.base / "work")) rc = cmd_convert( source_fw=source_fw, target_fw=target_fw, from_name="bot-a", local_dir=str(src), out_dir=str(out), - **extra, ) self.assertEqual(rc, 0, f"{source_fw}->{target_fw} convert failed") return _read_all(build_spec(target_fw, "bot-a", str(out)).workspace_root) @@ -555,81 +544,25 @@ def test_hermes_to_qwenpaw_identity_survives(self): self.assertIn("memory/USER.md", files) self.assertIn("HM_USER_MARKER", files["memory/USER.md"]) - def test_openclaw_to_ms_agent_memory_lands_in_work_dir(self): - """openclaw -> ms-agent: MEMORY.md lands in the project work dir - (``/.ms_agent/memory/MEMORY.md``), not folded into AGENTS.md - and not written as a dead global-home file.""" - work = self.base / "work" + def test_openclaw_to_ms_agent_memory_folds_into_agents(self): + """openclaw -> ms-agent: ms-agent has no memory slot (memory is + project-level at runtime), so MEMORY.md content is folded into the + catch-all AGENTS.md rather than written as a dead global file.""" files = self._convert( { "SOUL.md": "# Soul\nOC soul.\n", "MEMORY.md": "# Memory\nOC_MEM_MARKER.\n", }, "openclaw", "ms-agent", - work_dir=work, ) + # no standalone memory file in the ms-agent global layout. self.assertNotIn("MEMORY.md", files) - agents = files.get("AGENTS.md", "") - self.assertNotIn("OC_MEM_MARKER", agents) - mem = work / ".ms_agent" / "memory" / "MEMORY.md" - self.assertTrue(mem.is_file(), f"missing project memory at {mem}") - self.assertIn("OC_MEM_MARKER", mem.read_text(encoding="utf-8")) - from ms_agent.project.manager import ProjectManager - proj = ProjectManager( - base_dir=os.environ["MS_AGENT_HOME"]).find_by_path(str(work)) - self.assertIsNotNone(proj) - self.assertTrue(proj.memory_enabled) - self.assertEqual(proj.memory_backend or "file", "file") + # content is preserved by folding into the catch-all instructions file. + self.assertIn("AGENTS.md", files) + self.assertIn("OC_MEM_MARKER", files["AGENTS.md"]) + # single-agent target: no agent-prefixed dirs. self.assertFalse(any("bot-a" in p for p in files)) - def test_openclaw_to_ms_agent_does_not_switch_vector_backend(self): - """Convert still writes MEMORY.md, but must not flip an existing - vector-backend project onto file memory.""" - from ms_agent.project.manager import ProjectManager - work = self.base / "vector-work" - work.mkdir() - pm = ProjectManager(base_dir=os.environ["MS_AGENT_HOME"]) - proj = pm.open_folder(str(work)) - pm.update(proj.id, memory_enabled=True, memory_backend="vector") - self._convert( - { - "SOUL.md": "# Soul\nOC soul.\n", - "MEMORY.md": "# Memory\nKEEP_VECTOR.\n", - }, - "openclaw", "ms-agent", - work_dir=work, - ) - mem = work / ".ms_agent" / "memory" / "MEMORY.md" - self.assertTrue(mem.is_file()) - self.assertIn("KEEP_VECTOR", mem.read_text(encoding="utf-8")) - updated = pm.find_by_path(str(work)) - self.assertEqual(updated.memory_backend, "vector") - self.assertTrue(updated.memory_enabled) - - def test_ms_agent_work_dir_defaults_to_cwd(self): - """Omitted --work-dir writes MEMORY.md under the current directory.""" - cwd = self.base / "cwd-work" - cwd.mkdir() - src = self.base / "openclaw_cwd_src" - _write(build_spec("openclaw", "bot-a", str(src)).workspace_root, { - "SOUL.md": "# Soul\n", - "MEMORY.md": "# Memory\nCWD_MEM_MARKER.\n", - }) - out = self.base / "cwd-out" - old = os.getcwd() - os.chdir(cwd) - try: - rc = cmd_convert( - source_fw="openclaw", target_fw="ms-agent", - from_name="bot-a", local_dir=str(src), out_dir=str(out), - ) - finally: - os.chdir(old) - self.assertEqual(rc, 0) - mem = cwd / ".ms_agent" / "memory" / "MEMORY.md" - self.assertTrue(mem.is_file()) - self.assertIn("CWD_MEM_MARKER", mem.read_text(encoding="utf-8")) - class TestQoderPersonaOutbound(unittest.TestCase): """Converting OUT of qoder must not lose the per-agent persona file. diff --git a/tests/agent_hub/test_merge.py b/tests/agent_hub/test_merge.py index d8b717eee..c88083e49 100644 --- a/tests/agent_hub/test_merge.py +++ b/tests/agent_hub/test_merge.py @@ -276,9 +276,10 @@ def test_cross_product_memory_md(self): self.assertEqual( _resolve_target_path("hermes", "memories/MEMORY.md", "qoder"), "memory/MEMORY.md") - self.assertEqual( - _resolve_target_path("qoder", "memory/MEMORY.md", "ms-agent"), - "memory/MEMORY.md") + # ms-agent has no memory slot, so qoder memory has no semantic target + # there either (folds into the catch-all instead). + self.assertIsNone( + _resolve_target_path("qoder", "memory/MEMORY.md", "ms-agent")) def test_cross_product_ms_agent_profile(self): # qwenpaw has no USER.md slot (its profile lives in the composite @@ -309,17 +310,13 @@ def test_cross_product_qoder_user_md_is_loose_memory(self): _resolve_target_path("nanobot", "USER.md", "ms-agent"), "PROFILE.md") - def test_cross_product_ms_agent_memory_slot(self): - # ms-agent runtime memory is project-level. The merger still maps - # inbound MEMORY.md onto the semantic slot ``memory/MEMORY.md``; - # convert_workspace peels that file out of the global home and writes - # it under ``/.ms_agent/memory/MEMORY.md``. - self.assertEqual( - _resolve_target_path("openclaw", "MEMORY.md", "ms-agent"), - "memory/MEMORY.md") - self.assertEqual( - _resolve_target_path("nanobot", "memory/MEMORY.md", "ms-agent"), - "memory/MEMORY.md") + def test_cross_product_ms_agent_no_memory_slot(self): + # ms-agent has NO memory slot (memory is project-level at runtime, not + # part of the global home layout). An inbound MEMORY.md therefore has no + # semantic target and returns None, letting the merger fold it into the + # catch-all instructions file instead of writing a dead MEMORY.md. + self.assertIsNone(_resolve_target_path("openclaw", "MEMORY.md", "ms-agent")) + self.assertIsNone(_resolve_target_path("nanobot", "memory/MEMORY.md", "ms-agent")) def test_cross_product_no_mapping_passthrough(self): result = _resolve_target_path("nanobot", "skills/my-skill/SKILL.md", "openclaw") @@ -483,10 +480,10 @@ def test_loose_non_md_memory_not_rehomed(self): self.assertIn("memory/notes.json", result.merged_files) self.assertNotIn("memories/notes.json", result.merged_files) - def test_loose_memory_inlined_for_ms_agent(self): - """ms-agent runtime reads one MEMORY.md: loose topic detail is - inlined into ``memory/MEMORY.md`` (then convert peels that file - into the project work dir).""" + def test_loose_memory_keeps_path_for_ms_agent(self): + """ms-agent has no home-level memory slot: loose detail keeps its + original path at the merge level and the target-spec filter drops + it (memory stays out of ms-agent by design).""" result = merge_resources( incoming={"memory/t.md": "topic body\n"}, source_product="qoder", @@ -494,9 +491,8 @@ def test_loose_memory_inlined_for_ms_agent(self): source_defaults={}, target_defaults={}, ) - merged = result.merged_files["memory/MEMORY.md"] - self.assertIn("topic body", merged) - self.assertNotIn("memory/t.md", result.merged_files) + self.assertEqual(result.merged_files.get("memory/t.md"), + "topic body\n") def test_hermes_entry_budget_skips_overflow(self): """An entry that would bust hermes' 2200-char budget is SKIPPED diff --git a/tests/agent_hub/test_workspace.py b/tests/agent_hub/test_workspace.py index b25d8b75d..0acd782e4 100644 --- a/tests/agent_hub/test_workspace.py +++ b/tests/agent_hub/test_workspace.py @@ -2,7 +2,6 @@ """Sub-agent-aware workspace spec collection tests.""" import base64 import json -import os import tempfile import unittest from pathlib import Path @@ -599,36 +598,22 @@ def test_convert_end_to_end_picks_live_user_active_profile(self): "GOLD-WEATHER\n") (profile / "skills" / "weather" / "_meta.json").write_text('{"k": 1}') out = Path(self.tmp.name) / "out" - work = Path(self.tmp.name) / "work" - work.mkdir() - old_home = os.environ.get("MS_AGENT_HOME") - os.environ["MS_AGENT_HOME"] = str(Path(self.tmp.name) / "ms_home") - try: - rc = cmd_convert( - "openhuman", "ms-agent", None, None, - str(self.root), str(out), work_dir=str(work)) - self.assertEqual(rc, 0) - rels = { - str(p.relative_to(out)) for p in out.rglob("*") if p.is_file() - } - self.assertIn("skills/weather/SKILL.md", rels) - self.assertNotIn("skills/weather/_meta.json", rels) - all_text = "".join( - p.read_text(encoding="utf-8") for p in out.rglob("*") - if p.is_file()) - self.assertIn("GOLD-PERSONA", all_text) - self.assertIn("GOLD-WEATHER", all_text) - self.assertNotIn("GOLD-MEMORY", all_text) - mem = work / ".ms_agent" / "memory" / "MEMORY.md" - self.assertTrue(mem.is_file()) - self.assertIn("GOLD-MEMORY", mem.read_text(encoding="utf-8")) - # the stale shell's persona must NOT leak into the output - self.assertNotIn("stale soul", all_text) - finally: - if old_home is None: - os.environ.pop("MS_AGENT_HOME", None) - else: - os.environ["MS_AGENT_HOME"] = old_home + rc = cmd_convert("openhuman", "ms-agent", None, None, + str(self.root), str(out)) + self.assertEqual(rc, 0) + rels = { + str(p.relative_to(out)) for p in out.rglob("*") if p.is_file() + } + self.assertIn("skills/weather/SKILL.md", rels) + self.assertNotIn("skills/weather/_meta.json", rels) + all_text = "".join( + p.read_text(encoding="utf-8") for p in out.rglob("*") + if p.is_file()) + self.assertIn("GOLD-PERSONA", all_text) + self.assertIn("GOLD-MEMORY", all_text) + self.assertIn("GOLD-WEATHER", all_text) + # the stale shell's persona must NOT leak into the output + self.assertNotIn("stale soul", all_text) class TestOpenhumanActiveProfile(unittest.TestCase): diff --git a/tests/e2e/test_convert_memory.py b/tests/e2e/test_convert_memory.py deleted file mode 100644 index 3cc47e60d..000000000 --- a/tests/e2e/test_convert_memory.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Convert-to-ms-agent project memory landing (TUI / WebUI same file). - -Inbound MEMORY.md must land at ``/.ms_agent/memory/MEMORY.md`` — the -path both TUI (``apply_project_memory`` + FileBasedBackend) and WebUI -(``GET /api/projects/{id}/memory/doc``) read. Disk is the source of truth. -""" -from __future__ import annotations - -from pathlib import Path - -from omegaconf import OmegaConf - -from ms_agent.agent_hub._commands import build_spec, cmd_convert -from ms_agent.personalization.memory_apply import apply_project_memory -from ms_agent.project.manager import ProjectManager -from ms_agent.project.paths import memory_dir -from ms_agent.tui.app import TuiApp - -from tests.e2e.helpers import runtime_for - -MARKER = 'E2E_CONVERT_MEM_MARKER' - - -def _convert_openclaw_memory(work: Path, home_files: Path) -> int: - src = work.parent / 'openclaw_src' - src.mkdir(exist_ok=True) - root = build_spec('openclaw', 'default', str(src)).workspace_root - root.mkdir(parents=True, exist_ok=True) - (root / 'SOUL.md').write_text('# Soul\nconverted persona.\n', encoding='utf-8') - (root / 'MEMORY.md').write_text( - f'# Memory\n{MARKER}\n', encoding='utf-8') - return cmd_convert( - source_fw='openclaw', - target_fw='ms-agent', - from_name='default', - local_dir=str(src), - out_dir=str(home_files), - work_dir=str(work), - ) - - -class TestConvertMemoryLandsInOpenedWork: - def test_file_on_disk_and_project_memory_enabled( - self, isolated_home, work_dir): - rc = _convert_openclaw_memory(work_dir, work_dir.parent / 'ms_home_files') - assert rc == 0 - mem = memory_dir(work_dir) / 'MEMORY.md' - assert mem.is_file() - assert MARKER in mem.read_text(encoding='utf-8') - pm = ProjectManager(base_dir=str(isolated_home)) - proj = pm.find_by_path(str(work_dir)) - assert proj is not None - assert proj.memory_enabled is True - assert (proj.memory_backend or 'file') == 'file' - - def test_webui_adapter_and_http_read_the_same_file( - self, isolated_home, work_dir, webui, webui_client): - rc = _convert_openclaw_memory(work_dir, work_dir.parent / 'ms_home_files') - assert rc == 0 - pm = ProjectManager(base_dir=str(isolated_home)) - proj = pm.find_by_path(str(work_dir)) - assert proj is not None - - from app.backends.ms_agent import memory as webui_memory - doc = webui_memory.get_doc(proj.id) - assert MARKER in (doc.content or '') - - listed = webui.projects.get_project(proj.id) - assert listed.memory_enabled is True - - resp = webui_client.get(f'/api/projects/{proj.id}/memory/doc') - assert resp.status_code == 200 - body = resp.json() - assert body['code'] == 0 - assert MARKER in body['data']['content'] - - mem_path = memory_dir(work_dir) / 'MEMORY.md' - assert MARKER in mem_path.read_text(encoding='utf-8') - assert Path(proj.path).resolve() == work_dir.resolve() - - def test_tui_open_points_runtime_at_the_same_file( - self, isolated_home, work_dir): - from ms_agent.memory.unified.config import MemoryConfig - from ms_agent.memory.unified.storage.file_storage import ( - FileMemoryStorage, - ) - - rc = _convert_openclaw_memory(work_dir, work_dir.parent / 'ms_home_files') - assert rc == 0 - tui_proj = TuiApp._open_project(str(work_dir)) - assert tui_proj.memory_enabled is True - rt = runtime_for(work_dir) - kind = apply_project_memory(rt.config, tui_proj) - assert kind == 'file' - node = OmegaConf.select(rt.config, 'memory.unified_memory') - assert node is not None - storage = FileMemoryStorage( - MemoryConfig(base_dir=str(memory_dir(work_dir)))) - assert MARKER in storage.get_content() - pm = ProjectManager(base_dir=str(isolated_home)) - registered = pm.find_by_path(str(work_dir)) - assert registered is not None - assert tui_proj.id == registered.id From d9a2e4e5577dc7f500d59cfb1c477964df644459 Mon Sep 17 00:00:00 2001 From: suluyan Date: Fri, 9 Oct 2026 11:16:54 +0800 Subject: [PATCH 04/21] fix(tui): apply shared ledger changes on the next user message Slash commands keep writing the WebUI ledger. The same agent refreshes skills, MCP, the model client, and memory on the next message, and model saves roll back when the catalog rejects the key or the model id. Co-authored-by: Cursor --- docs/tui-webui-align-e2e.md | 18 +- ms_agent/agent/llm_agent.py | 28 ++ ms_agent/command/builtin/config_cmds.py | 289 +++++++++++++++--- ms_agent/command/builtin/memory_cmds.py | 22 +- ms_agent/command/builtin/resource_cmds.py | 111 +++++-- ms_agent/command/interactive.py | 19 ++ ms_agent/command/usage.py | 20 +- ms_agent/llm/model_discovery.py | 111 +++++-- ms_agent/llm/runtime_token.py | 24 ++ .../memory/unified/backends/file_based.py | 16 +- ms_agent/memory/unified/orchestrator.py | 20 +- .../memory/unified/storage/file_storage.py | 28 +- ms_agent/memory/unified/user_notice.py | 10 + ms_agent/prompting/workspace_files.py | 6 +- ms_agent/skills/update-config/SKILL.md | 8 +- ms_agent/tui/app.py | 36 ++- ms_agent/tui/input.py | 5 + ms_agent/tui/live_config.py | 186 +++++++++++ tests/command/test_interactive_input.py | 33 ++ tests/command/test_memory_cmds.py | 3 +- tests/command/test_new_cmds.py | 286 ++++++++++++----- tests/command/test_resource_cmds.py | 70 +++++ tests/llm/test_model_discovery.py | 41 +++ tests/memory/test_file_storage_notice.py | 26 ++ tests/tui/test_live_config.py | 70 +++++ tests/tui/test_tui_config_align.py | 42 +++ 26 files changed, 1320 insertions(+), 208 deletions(-) create mode 100644 ms_agent/llm/runtime_token.py create mode 100644 ms_agent/memory/unified/user_notice.py create mode 100644 ms_agent/tui/live_config.py create mode 100644 tests/memory/test_file_storage_notice.py create mode 100644 tests/tui/test_live_config.py diff --git a/docs/tui-webui-align-e2e.md b/docs/tui-webui-align-e2e.md index 527df8728..cc18f608c 100644 --- a/docs/tui-webui-align-e2e.md +++ b/docs/tui-webui-align-e2e.md @@ -19,7 +19,7 @@ mkdir -p "$MS_AGENT_HOME" /tmp/align-work ms-agent tui --work-dir /tmp/align-work ``` -换期前可清空 `$MS_AGENT_HOME`。磁盘是唯一验收源;当前这一轮对话若没跟上,脚本会写「需 `/new`」。 +换期前可清空 `$MS_AGENT_HOME`。磁盘是唯一验收源。账本改完后,同一会话的下一条用户消息才生效,不必 `/new`。 对齐标准:**同一账本**(TUI slash 能管 WebUI 设置页写的那些文件),不是 TUI/WebUI 界面完全一致。 @@ -96,7 +96,7 @@ ms-agent tui --work-dir /tmp/align-work | E5 | 启用/停用 | `/mcp enable\|disable [global\|project]` | WebUI 开关一致 | | E6 | 删除 | `/mcp remove [global\|project]` | WebUI 不再显示(或项目级遮罩全局) | | E7 | 导入 json | `/mcp json ` | 能进 mcp.json | -| E8 | 本会话连接 | add/update 后 | 能连则立刻连;否则提示 `/new` | +| E8 | 本会话连接 | add/update/enable/disable/remove 之后,再发一条用户消息 | 只改了的服务器会连上或断开;没改的保持连接。命令只回「下一条消息生效。」 | ### F. 技能(第 0 / 4 期) @@ -125,7 +125,7 @@ ms-agent tui --work-dir /tmp/align-work | G4 | 本项目开/关 | `/memory on\|off` 或 `/memory project on\|off` | WebUI 该项目记忆开关一致;file 时对话能走到 unified_memory | | G5 | 后端 | `/memory backend file\|vector`(全局默认);`/memory project backend file\|vector`(本项目) | file:TUI 用 MEMORY.md;vector:只落盘给 WebUI,**TUI 不得悄悄写成 file** | | G6 | WebUI → TUI | WebUI 打开同一项目的 file 记忆 | 新开 TUI `/memory` 项目为 on | -| G7 | 中途关掉 | 已经 load 过记忆后再 `/memory off` | 提示 `/new` 才卸工具 | +| G7 | 中途关掉 | 已经 load 过记忆后再 `/memory off`,再发一条用户消息 | 记忆工具卸掉;不必 `/new` | ### 本会话未覆盖(不要当成回归失败) @@ -371,7 +371,7 @@ TUI 命令: /memory project backend file|vector ``` -TUI 的 vector/mem0 不在本期接;选 vector 只落盘给 WebUI 用,**不会**悄悄改写成 file。当前会话若已加载过记忆工具,关记忆后需要 `/new`。 +TUI 的 vector/mem0 不在本期接;选 vector 只落盘给 WebUI 用,**不会**悄悄改写成 file。开关和后端在同一会话的下一条用户消息生效,不需要 `/new`。 ### 5.1 全局默认 → 新项目 @@ -384,7 +384,7 @@ TUI 的 vector/mem0 不在本期接;选 vector 只落盘给 WebUI 用,**不 1. 同一工作目录。TUI:`/memory on`(或 `/memory project on`) 2. 项目 meta 里 `memory_enabled` 为 true。 -3. `/new` 后再请模型「记住我喜欢快排」之类;应能走到 `unified_memory` 工具(file 后端)。 +3. 下一条用户消息再请模型「记住我喜欢快排」之类;应能走到 `unified_memory` 工具(file 后端)。不需要 `/new`。 4. 刷新 WebUI 该项目记忆开关:应为开;`MEMORY.md` 若已写入,WebUI 记忆页能看到。 ### 5.3 WebUI → TUI @@ -393,3 +393,11 @@ TUI 的 vector/mem0 不在本期接;选 vector 只落盘给 WebUI 用,**不 2. 新开 TUI 同一 `--work-dir`:`/memory` 项目应为 on;配置里有 `memory.unified_memory`。 **失败:** 只改了全局 settings、当前项目对话仍没有记忆工具;或 TUI 把 vector 项目悄悄写成了 MEMORY.md。 + +--- + +## 后续 + +内置技能 `update-config` 先不做成两端技能页都能看到的管理项。配置说明走 `ms_agent/prompting` 里已有的提示词(文件位置、下一轮生效),用来关掉「管理界面看不到、对话里却在用」这一条。 + +更完整的 harness 说明以后用文档技能做,不继续往系统提示词里加手册。 diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 0906b6ac5..d28577410 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -337,6 +337,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. @@ -1106,6 +1112,13 @@ async def _quiet(awaitable, label: str) -> None: await flush(timeout=15) 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): @@ -2392,9 +2405,19 @@ 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) + 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.""" @@ -2761,6 +2784,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. @@ -2955,6 +2980,9 @@ async def run_loop(self, messages: Union[List[Message], str], messages, add_type='add_after_step', **kwargs) 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) # New user turn (interactive multi-turn): attach the durable # augmentations BEFORE the slice below persists them — same # semantics as the round-0 attach. Order: state notice first diff --git a/ms_agent/command/builtin/config_cmds.py b/ms_agent/command/builtin/config_cmds.py index 4a395b82a..d5aad0d27 100644 --- a/ms_agent/command/builtin/config_cmds.py +++ b/ms_agent/command/builtin/config_cmds.py @@ -3,7 +3,8 @@ 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, same_as_webui, status_then_usage +from ms_agent.command.usage import (arg_error, contains_secret, same_as_webui, + status_then_usage) CMD_MODEL = CommandDef( name='model', @@ -100,6 +101,37 @@ def _builtin_ids() -> set[str]: 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 head != head.lower(): + 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) @@ -335,6 +367,20 @@ def _effective_api_key(pid: str, override: dict) -> str: 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) + llm = getattr(config, 'llm', None) + if spec is None or llm is None: + return '' + try: + return str(CredentialResolver.resolve_api_key(spec, llm) or '').strip() + except Exception: + return '' + + def _effective_base_url(pid: str, override: dict) -> str: url = str(override.get('base_url') or '').strip() if url: @@ -423,6 +469,8 @@ def _provider_status_lines(mgr, *, live: bool = False, 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 @@ -471,8 +519,7 @@ def _push_provider_to_runtime(ctx: CommandContext, provider_id: str) -> str: f' Switch with /model {provider_id} to use this ' 'provider live.') TuiApp._apply_provider_credentials(config, overwrite=True) - _rebuild_llm(ctx) - return ' Live credentials applied.' + return ' 下一条消息生效。' def _split_kv(tokens: list[str]) -> tuple[list[str], dict[str, str]]: @@ -551,7 +598,120 @@ async def cmd_model(ctx: CommandContext) -> CommandResult: 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 = '已保存。这个地址没有返回模型列表,下一次对话才会确认模型名。' + + +def _probe_saved_provider(pid: str): + """Catalog probe for the provider as currently saved. None without key+url.""" + 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 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 key or URL write. + + Returns ``(reject_text, saved_note)``. ``reject_text`` is set only when + the write was rolled back. ``saved_note`` is the no-catalog sentence. + """ + if not 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 + _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 @@ -565,30 +725,63 @@ def _cmd_model_switch(ctx: CommandContext, arg: str) -> CommandResult: config, target) new_model = _strip_provider_prefix( service_override or old_service, new_model) + if contains_secret(new_model): + return _secret_model_error(ctx) - 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) - - if not _rebuild_llm(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) + + 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), ) - settings_provider = service_override or str( - getattr(getattr(config, 'llm', None), 'service', '') or '') or None + from ms_agent.llm.model_discovery import nearby_model_ids + probe = _probe_saved_provider(pid) + 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) content = 'Switched to:\n' + _current_model_text( settings_provider, new_model, ctx.runtime.llm) - content += 'Saved as the default (same as WebUI).' + content += 'Saved as the default (same as WebUI). 下一条消息使用它。' + if probe is not None and probe.outcome == 'no_catalog': + content += '\n' + _NO_CATALOG return CommandResult( type=CommandResultType.MUTATE_STATE, content=content, @@ -618,6 +811,7 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult ) pid = names[0] fields = _kv_alias(fields) + before = _provider_snapshot(pid) mgr.add_provider( pid, name=fields.get('name') or pid, @@ -625,11 +819,9 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult api_key=fields.get('api_key'), base_url=fields.get('base_url'), ) - note = _push_provider_to_runtime(ctx, pid) - return CommandResult( - type=CommandResultType.MESSAGE, - content=f'Provider {pid} saved.{note}', - ) + 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: @@ -645,6 +837,7 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult reason='No fields to patch', ctx=ctx, ) + before = _provider_snapshot(pid) mgr.patch_provider( pid, name=fields.get('name'), @@ -654,11 +847,9 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult clear_api_key=fields.get('api_key', None) == '', clear_base_url=fields.get('base_url', None) == '', ) - note = _push_provider_to_runtime(ctx, pid) - return CommandResult( - type=CommandResultType.MESSAGE, - content=f'Updated provider {pid}.{note}', - ) + 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( @@ -673,12 +864,10 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult type=CommandResultType.MESSAGE, content=f'Cleared API key for {pid}.{note}', ) + before = _provider_snapshot(pid) mgr.patch_provider(pid, api_key=value) - note = _push_provider_to_runtime(ctx, pid) - return CommandResult( - type=CommandResultType.MESSAGE, - content=f'Saved API key for {pid}.{note}', - ) + 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( @@ -693,12 +882,10 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult type=CommandResultType.MESSAGE, content=f'Cleared base URL for {pid}.{note}', ) + before = _provider_snapshot(pid) mgr.patch_provider(pid, base_url=value) - note = _push_provider_to_runtime(ctx, pid) - return CommandResult( - type=CommandResultType.MESSAGE, - content=f'Saved base URL for {pid}.{note}', - ) + return _finish_credential_write( + ctx, pid, before, {'base_url'}, f'Saved base URL for {pid}.') if action == 'remove': if not rest: return arg_error( @@ -804,6 +991,13 @@ def _cmd_model_catalog(ctx: CommandContext, 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, @@ -831,6 +1025,32 @@ def _cmd_model_catalog(ctx: CommandContext, ) +def _mask_config_secrets(node) -> None: + """Blank secret-shaped keys and entire ``headers`` / ``env`` bags. + + Same vocabulary as convert upload (``is_secret_key``), so ``/config`` + masks search keys and MCP header tokens the way it already masks + ``llm`` API keys. + """ + from ms_agent.agent_hub._workspace import SECRET_BAG_KEYS, is_secret_key + + if isinstance(node, dict): + for key, val in list(node.items()): + if key in SECRET_BAG_KEYS and isinstance(val, dict): + node[key] = { + inner: '***' if inner_val not in (None, '') else inner_val + for inner, inner_val in val.items() + } + elif is_secret_key(str(key)) and not isinstance(val, (dict, list)): + if val not in (None, ''): + node[key] = '***' + 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( @@ -839,11 +1059,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/memory_cmds.py b/ms_agent/command/builtin/memory_cmds.py index 49a256ea9..c8627fac0 100644 --- a/ms_agent/command/builtin/memory_cmds.py +++ b/ms_agent/command/builtin/memory_cmds.py @@ -83,26 +83,14 @@ async def _apply_live(ctx: CommandContext, project) -> str: agent = ctx.runtime cfg = getattr(agent, 'config', None) if agent is not None else None if cfg is None: - return 'Takes effect on /new or restart.' + return '已保存。下一条消息生效。' kind = apply_project_memory(cfg, project) - if kind == 'off': - return 'Saved. /new to drop memory tools already loaded this session.' if kind == 'vector-unavailable': return ( 'Saved vector backend for WebUI. TUI does not start vector/mem0 ' - 'this session (no silent file fallback). Use /memory project ' - 'backend file or open the project in WebUI.') - tools = getattr(agent, 'memory_tools', None) or [] - if tools: - return 'Saved. Memory already loaded; /new to rebuild.' - load = getattr(agent, 'load_memory', None) - if load is None: - return 'Saved. /new to apply.' - try: - await load() - return 'Memory tools registered for this session.' - except Exception as exc: # noqa: BLE001 - return f'Saved; load failed ({exc}). /new to apply.' + '(no silent file fallback). Use /memory project backend file ' + 'or open the project in WebUI.') + return '下一条消息生效。' async def cmd_memory(ctx: CommandContext) -> CommandResult: @@ -212,7 +200,7 @@ def _cmd_backend( type=CommandResultType.MESSAGE, content=( f'Project memory backend → {backend}. ' - '/memory on (and /new) to apply. Vector is WebUI-owned.'), + '下一条消息生效。Vector is WebUI-owned.'), ) settings = PersonalizationSettings() diff --git a/ms_agent/command/builtin/resource_cmds.py b/ms_agent/command/builtin/resource_cmds.py index 9d412aa99..374c7626a 100644 --- a/ms_agent/command/builtin/resource_cmds.py +++ b/ms_agent/command/builtin/resource_cmds.py @@ -47,8 +47,10 @@ def _skill_usage() -> str: ' /skills remove [global|project]\n' f'Directory drop-in: global → {ledger_dir()}/skills, ' 'project → /.ms_agent/skills. ' - 'Omit scope: this folder (project) when TUI has --work-dir, ' + 'Omit scope on add: this folder (project) when a work dir is open, ' 'otherwise this machine (global). ' + 'Omit scope on enable, disable, or remove: the copy that already ' + 'exists; if both exist, write global or project. ' f'{same_as_webui("skills")} ' 'remove only deletes a managed copy, not auto-discovered skills.' ) @@ -66,12 +68,28 @@ def _home_work(ctx: CommandContext) -> tuple[str, str | None]: def _parse_scope(tokens: list[str], default: str = 'project') -> tuple[str, list[str]]: - """Optional last-token scope so a path/name can contain spaces.""" + """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] = {} @@ -169,7 +187,7 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: if not tokens: return arg_error( f'/mcp {action} [global|project]', ctx=ctx) - scope, rest = _parse_scope(tokens, default='project') + scope, rest, _ = _parse_named_scope(tokens, default='project') miss = _need_work(scope, work) if miss: return miss @@ -193,8 +211,7 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: if action == 'add': rest, fields = _split_kv(tokens) - explicit = bool(rest and rest[-1] in ('global', 'project')) - scope, rest = _parse_scope(rest, default='project') + scope, rest, explicit = _parse_named_scope(rest, default='project') miss = _need_work(scope, work) if miss: return miss @@ -221,7 +238,7 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: if action == 'update': rest, fields = _split_kv(tokens) - scope, rest = _parse_scope(rest, default='project') + scope, rest, _ = _parse_named_scope(rest, default='project') miss = _need_work(scope, work) if miss: return miss @@ -251,21 +268,9 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: async def _reload_mcp(ctx: CommandContext) -> str: - home, work = _home_work(ctx) - from ms_agent.tui.managed_config import resolve_mcp_config - cfg = resolve_mcp_config(home, work) - agent = ctx.runtime - tm = getattr(agent, 'tool_manager', None) if agent is not None else None - client = getattr(tm, 'servers', None) if tm is not None else None - if client is None or not cfg or not hasattr(client, 'add_mcp_config'): - return 'Takes effect on /new or restart.' - try: - await client.add_mcp_config(cfg) - if hasattr(tm, 'reindex_tool'): - await tm.reindex_tool() - return 'Connected for this session.' - except Exception as exc: # noqa: BLE001 - surface, do not crash TUI - return f'Saved; connect failed ({exc}). /new or restart to apply.' + """Ledger is already written. The live runtime applies it next message.""" + del ctx + return '下一条消息生效。' async def cmd_skills(ctx: CommandContext) -> CommandResult: @@ -303,7 +308,9 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: tokens, work, syntax=f'/skills {action} [global|project]', - ctx=ctx) + ctx=ctx, + mgr=mgr, + action=action) if isinstance(resolved, CommandResult): return resolved scope, rest = resolved @@ -349,7 +356,9 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: tokens, work, syntax='/skills remove [global|project]', - ctx=ctx) + ctx=ctx, + mgr=mgr, + action='remove') if isinstance(resolved, CommandResult): return resolved scope, rest = resolved @@ -378,18 +387,74 @@ def _default_skill_scope(work: str | None) -> str: return 'project' if work else 'global' +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 + + 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': + return arg_error( + syntax, + reason=f'{skill_id} exists in both global and project', + note='Pass global or project.', + ctx=ctx, + ) + if picked: + scope = picked if scope is None: scope = _default_skill_scope(work) miss = _need_work(scope, work) diff --git a/ms_agent/command/interactive.py b/ms_agent/command/interactive.py index 22e4ebc83..6ae068a96 100644 --- a/ms_agent/command/interactive.py +++ b/ms_agent/command/interactive.py @@ -144,9 +144,28 @@ 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) + 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: from ms_agent.ui.events import Notice diff --git a/ms_agent/command/usage.py b/ms_agent/command/usage.py index f158cacc9..81f30aaf4 100644 --- a/ms_agent/command/usage.py +++ b/ms_agent/command/usage.py @@ -46,6 +46,24 @@ def status_then_usage(status: str, usage: str) -> str: 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, *, @@ -69,7 +87,7 @@ def arg_error( lines.append(text) lines.append(f'Need: {syntax}') if got: - lines.append(f'Got: {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/llm/model_discovery.py b/ms_agent/llm/model_discovery.py index ce07f6e0b..117e225bc 100644 --- a/ms_agent/llm/model_discovery.py +++ b/ms_agent/llm/model_discovery.py @@ -8,7 +8,8 @@ from __future__ import annotations import re -from typing import Iterable, List, Sequence, Tuple +from dataclasses import dataclass +from typing import Iterable, List, Optional, Sequence, Tuple import httpx @@ -65,31 +66,103 @@ def wire_protocol(protocol_or_transport: str) -> str: return 'openai' -def fetch_model_ids(base_url: str, protocol: str, api_key: str) -> List[str]: - """Return available model ids, or [] on any failure.""" +#: 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', +) + + +@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 [] + 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: - 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}' with httpx.Client(timeout=8) as client: resp = client.get(url, headers=headers) - if resp.status_code // 100 != 2: - return [] - return parse_model_ids(resp.json()) + 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 format_id_list(ids: Iterable[str], *, limit: int = 40) -> str: 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/memory/unified/backends/file_based.py b/ms_agent/memory/unified/backends/file_based.py index 81f7d2fa3..e83504938 100644 --- a/ms_agent/memory/unified/backends/file_based.py +++ b/ms_agent/memory/unified/backends/file_based.py @@ -250,14 +250,22 @@ 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) result = '已删除' if ok else '删除失败' diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py index a5c47aa16..89d6a8e19 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() @@ -306,9 +307,16 @@ async def _ingest(self, msg_dicts: List[Dict[str, Any]], except asyncio.CancelledError: self._set_status('error', error='cancelled') raise + except OSError: + from ms_agent.memory.unified.user_notice import DISK_NOTICE + self.user_notice = DISK_NOTICE + logger.error(DISK_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}') + logger.debug('[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 +347,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..6c35743fb 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: - logger.warning( - f'[file_storage] MEMORY.md would exceed char limit ' - f'({len(new_content)} > {self.char_limit}), skipping add') + from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE + self.user_notice = CHAR_LIMIT_NOTICE + logger.debug( + '[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,7 +124,9 @@ 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: - logger.warning('[file_storage] Replace would exceed char limit') + from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE + self.user_notice = CHAR_LIMIT_NOTICE + logger.debug('[file_storage] Replace would exceed char limit') return False self._write(updated) return True @@ -144,8 +151,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 +196,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: + from ms_agent.memory.unified.user_notice import DISK_NOTICE + self.user_notice = DISK_NOTICE + 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..56e7f201f --- /dev/null +++ b/ms_agent/memory/unified/user_notice.py @@ -0,0 +1,10 @@ +# 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. +""" + +CHAR_LIMIT_NOTICE = '记忆写不进去:已超过字数上限。请删短 MEMORY.md 后再试。' +DISK_NOTICE = '记忆写不进去:磁盘或权限不允许写入。请检查记忆目录的权限和剩余空间。' +QUIT_WHILE_WRITING = '记忆还在写入,这次退出可能没保存完。请重新打开会话确认 MEMORY.md。' diff --git a/ms_agent/prompting/workspace_files.py b/ms_agent/prompting/workspace_files.py index c5e5f7a1a..210508a0a 100644 --- a/ms_agent/prompting/workspace_files.py +++ b/ms_agent/prompting/workspace_files.py @@ -464,8 +464,8 @@ 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(project_instruction_path(work_dir), - body + '\n' if body else '') + 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) @@ -538,7 +538,7 @@ def _migrate_legacy_project_instruction(work_dir: str) -> str: legacy = (project.instruction or '').strip() if not legacy: return existing - _atomic_write(path, legacy + '\n') + atomic_write_text(path, legacy + '\n') _clear_project_instruction_field(work_dir) return legacy + '\n' except Exception: diff --git a/ms_agent/skills/update-config/SKILL.md b/ms_agent/skills/update-config/SKILL.md index 54ebf7c0d..796c82d93 100644 --- a/ms_agent/skills/update-config/SKILL.md +++ b/ms_agent/skills/update-config/SKILL.md @@ -26,7 +26,9 @@ if scope is unclear. 1. Facts live in `{memory_md}`. 2. Creating that file does **nothing** until the **project** memory flag is - on. TUI: `/memory on` then `/new`. WebUI: the project memory toggle. + on. TUI: `/memory on` — the next user message in this session picks it + up. WebUI: the project memory toggle. Do not tell the user to `/new` + first. 3. **Do not** add `memory.unified_memory` (or any memory block) to `{work}/.ms_agent/config.yaml` or to agent.yaml. That is not how this product enables memory. @@ -69,8 +71,8 @@ stdio: } ``` -New servers connect this session when possible; otherwise tell the user -`/new` or restart. +Edits apply on the next user message in this session. Do not tell the +user to `/new` or restart for a server that was just saved. ## Workflow diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index e9bf08fa0..39e1e6b71 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -57,6 +57,9 @@ # (update-config, …). An empty ``{}`` is falsy under OmegaConf. '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, }, } @@ -131,11 +134,25 @@ 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). @@ -685,7 +702,7 @@ async def _serve(self) -> 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._sm.create(model=self._model or None) + self.session = self._sm.create(model=self.state.model or None) 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') @@ -720,9 +737,9 @@ async def _serve(self) -> None: resume = True continue except Exception as e: # noqa: BLE001 — surface, don't crash the REPL - if isinstance(e, RuntimeError) and ( - 'cancel scope' in str(e) or 'athrow()' in str(e)): - break + # 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() if self._is_missing_api_key(e): logger.info('TUI waiting for API key: %s', e) @@ -744,6 +761,13 @@ async def _serve(self) -> None: # (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, + chat_failed_because_unsupported, + ) + model = self.state.model or '' + if chat_failed_because_unsupported(model, str(e)): + self.console.print(CHAT_UNSUPPORTED) self.console.print( '[dim](turn failed — session still open, ' '/model to switch, /quit to exit)[/]') @@ -761,7 +785,7 @@ 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._sm.create(model=self.state.model or None) self._owned_session_ids.add(self.session.id) resume = False self.renderer.rule(f'new session {self.session.id}', 'green') diff --git a/ms_agent/tui/input.py b/ms_agent/tui/input.py index 6806239b7..64232e7e0 100644 --- a/ms_agent/tui/input.py +++ b/ms_agent/tui/input.py @@ -116,6 +116,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 = [] diff --git a/ms_agent/tui/live_config.py b/ms_agent/tui/live_config.py new file mode 100644 index 000000000..cf149fe21 --- /dev/null +++ b/ms_agent/tui/live_config.py @@ -0,0 +1,186 @@ +# 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 + + +async def _sync_llm(agent) -> None: + from ms_agent.llm import LLM + from ms_agent.llm.runtime_token import llm_runtime_token + if agent.llm is None or getattr(agent.llm, '_setup_stub', False): + return + token = llm_runtime_token(agent.config) + if getattr(agent, '_llm_fp', None) == token: + return + try: + rebuilt = LLM.from_config(agent.config) + except Exception: + logger.debug('live llm rebuild skipped', exc_info=True) + return + if rebuilt is None: + return + _install_llm(agent, rebuilt) + agent._llm_fp = token + 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_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) + if getattr(agent, '_mcp_fp', None) == token: + return + try: + await runtime.apply_config(ResolvedMCPConfig(mcp_servers=servers)) + except Exception: + logger.debug('live mcp apply failed', exc_info=True) + return + 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: + 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: + 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() + + +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: + _sync_skills(agent, messages) + await _sync_mcp(agent) + await _sync_llm(agent) + await _sync_memory(agent) + except Exception: + logger.debug('live config sync failed', exc_info=True) diff --git a/tests/command/test_interactive_input.py b/tests/command/test_interactive_input.py index 2ccd64c3e..340d996ff 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. diff --git a/tests/command/test_memory_cmds.py b/tests/command/test_memory_cmds.py index 2683b4566..365f60280 100644 --- a/tests/command/test_memory_cmds.py +++ b/tests/command/test_memory_cmds.py @@ -76,7 +76,8 @@ async def test_project_toggle_writes_meta_and_config( node = OmegaConf.select(runtime.config, 'memory.unified_memory') assert node is not None assert node.storage.backend == 'file' - assert runtime.memory_tools == ['loaded'] + assert '下一条消息生效' in result.content + assert runtime.memory_tools == [] @pytest.mark.asyncio async def test_project_toggle_sees_work_dir_on_agent_runtime( diff --git a/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py index a3fe42e20..eb81c4e97 100644 --- a/tests/command/test_new_cmds.py +++ b/tests/command/test_new_cmds.py @@ -50,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) @@ -129,6 +143,14 @@ 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): @@ -139,37 +161,25 @@ async def test_show_current_model(self): assert 'openai' in result.content @pytest.mark.asyncio - async def test_switch_model(self): - from unittest.mock import patch + async def test_switch_model(self, monkeypatch): runtime = MockRuntime() router = make_router() - - class Rebuilt: - def __init__(self): - self.config = runtime.llm.config - self.model = 'gpt-4o' - - with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): - result = await router.dispatch( - make_ctx('/model gpt-4o', runtime=runtime)) + _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_slash_in_model_id_stays_on_current_provider(self): - from unittest.mock import patch + async def test_slash_in_model_id_stays_on_current_provider(self, monkeypatch): runtime = MockRuntime() router = make_router() - - class Rebuilt: - def __init__(self): - self.config = runtime.llm.config - self.model = 'MiniMax/MiniMax-M2.1' - - with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): - result = await router.dispatch( - make_ctx('/model MiniMax/MiniMax-M2.1', runtime=runtime)) + _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' @@ -177,41 +187,28 @@ def __init__(self): assert 'Model: MiniMax/MiniMax-M2.1' in result.content @pytest.mark.asyncio - async def test_known_provider_then_slashy_model(self): - from unittest.mock import patch + async def test_known_provider_then_slashy_model(self, monkeypatch): runtime = MockRuntime() router = make_router() - - class Rebuilt: - def __init__(self): - self.config = runtime.llm.config - self.model = 'MiniMax/MiniMax-M2.1' - self.spec = type('S', (), {'name': 'dashscope'})() - self.transport = type('T', (), {'base_url': 'https://ds'})() - - with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): - result = await router.dispatch( - make_ctx( - '/model dashscope/MiniMax/MiniMax-M2.1', runtime=runtime)) + _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): - from unittest.mock import patch + async def test_space_form_sets_provider_when_model_has_slash( + self, monkeypatch): runtime = MockRuntime() - - class Rebuilt: - def __init__(self): - self.config = runtime.llm.config - self.model = 'MiniMax/MiniMax-M2.1' - - with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): - result = await make_router().dispatch( - make_ctx( - '/model dashscope MiniMax/MiniMax-M2.1', runtime=runtime)) + _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' @@ -257,6 +254,10 @@ async def test_switch_fail_hints_keyed_provider(self, monkeypatch): '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( @@ -347,30 +348,25 @@ async def test_show_current_separates_provider_and_model(self): assert 'Switch: /model dashscope MiniMax/MiniMax-M2.1' in result.content @pytest.mark.asyncio - async def test_switch_model_replaces_setup_stub(self): + async def test_switch_model_replaces_setup_stub(self, monkeypatch): from types import SimpleNamespace - from unittest.mock import patch 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) - - class Rebuilt: - def __init__(self): - self.config = config - self.model = 'qwen3.8-flash' - - with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): - result = await router.dispatch(ctx) + _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): + 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' @@ -390,15 +386,9 @@ async def test_switch_model_persists_to_settings_not_project_patch(self, tmp_pat runtime = MockRuntime(llm=MockLLM(model='qwen3.5-plus', config=config)) router = make_router() ctx = make_ctx('/model qwen3.7-max', runtime=runtime) - - class Rebuilt: - def __init__(self): - self.config = config - self.model = 'qwen3.7-max' - - from unittest.mock import patch - with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): - result = await router.dispatch(ctx) + _keyed('openai') + _catalog_ok(monkeypatch, ['qwen3.7-max']) + result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE assert 'Saved as the default' in result.content @@ -417,20 +407,14 @@ def __init__(self): 'openai/qwen3.7-max') @pytest.mark.asyncio - async def test_switch_model_no_source_file(self, tmp_path): + 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) - - class Rebuilt: - def __init__(self): - self.config = runtime.llm.config - self.model = 'gpt-4o' - - from unittest.mock import patch - with patch('ms_agent.llm.LLM.from_config', return_value=Rebuilt()): - result = await router.dispatch(ctx) + _keyed('openai') + _catalog_ok(monkeypatch, ['gpt-4o']) + result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE assert 'Saved as the default' in result.content from ms_agent.config.model_settings import ModelSettingsManager @@ -472,6 +456,10 @@ async def test_model_list_live_filters_and_does_not_persist( '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( @@ -511,7 +499,11 @@ def fake(url, protocol, api_key): assert 'skipped (no API key)' in skipped.content @pytest.mark.asyncio - async def test_provider_add_set_key_catalog(self, tmp_path): + 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( @@ -656,6 +648,52 @@ async def test_cannot_remove_builtin_without_override(self): 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_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): router = make_router() @@ -688,6 +726,38 @@ 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', + }, + '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', + }, + }, + }, + }) + 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 + class TestQuit: @pytest.mark.asyncio @@ -861,3 +931,61 @@ def test_total_builtin_count(self): cmds = router.list_commands('cli') total = sum(len(v) for v in cmds.values()) 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_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 index 597347fd2..eef782789 100644 --- a/tests/command/test_resource_cmds.py +++ b/tests/command/test_resource_cmds.py @@ -317,6 +317,76 @@ async def test_disable_skill_named_project_is_not_a_scope( proj = SkillsConfigManager(str(isolate_home)).load_project(str(work)) assert 'project' in proj.get('disabled', []) + @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 + + @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')) diff --git a/tests/llm/test_model_discovery.py b/tests/llm/test_model_discovery.py index 964168f2b..f93b0ea26 100644 --- a/tests/llm/test_model_discovery.py +++ b/tests/llm/test_model_discovery.py @@ -119,6 +119,47 @@ def test_fetch_model_ids_ok(): assert kwargs['headers']['Authorization'] == 'Bearer sk' +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_fetch_model_ids_non_2xx(): resp = MagicMock(status_code=401) client = MagicMock() diff --git a/tests/memory/test_file_storage_notice.py b/tests/memory/test_file_storage_notice.py new file mode 100644 index 000000000..5a0b51b17 --- /dev/null +++ b/tests/memory/test_file_storage_notice.py @@ -0,0 +1,26 @@ +"""Memory tool results the user can act on.""" +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.user_notice import CHAR_LIMIT_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 diff --git a/tests/tui/test_live_config.py b/tests/tui/test_live_config.py new file mode 100644 index 000000000..fa1c51669 --- /dev/null +++ b/tests/tui/test_live_config.py @@ -0,0 +1,70 @@ +# 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() diff --git a/tests/tui/test_tui_config_align.py b/tests/tui/test_tui_config_align.py index db750ae77..c1e495771 100644 --- a/tests/tui/test_tui_config_align.py +++ b/tests/tui/test_tui_config_align.py @@ -352,6 +352,7 @@ async def test_serve_keeps_repl_after_turn_api_error(): 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() @@ -388,3 +389,44 @@ async def fake_run(query=None, stream=True): 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) + + await app._serve() + out = buf.getvalue() + assert runs['n'] == 2 + assert 'session still open' in out + assert 'bye' in out From 4d734e28d4e927cdef8bd0d0061eb9f2e1778d1c Mon Sep 17 00:00:00 2001 From: suluyan Date: Fri, 9 Oct 2026 17:14:23 +0800 Subject: [PATCH 05/21] fix(tui): keep each conversation's model and stop mentioning WebUI New chats start from the global default. Switching updates that default and the current session, while resuming an older chat restores its own model. Slash output no longer tells a TUI user the files are shared with WebUI. Co-authored-by: Cursor --- docs/tui-webui-align-e2e.md | 4 +- ms_agent/command/builtin/config_cmds.py | 28 ++++--- ms_agent/command/builtin/instruction_cmds.py | 9 +-- ms_agent/command/builtin/memory_cmds.py | 34 ++++---- ms_agent/command/builtin/resource_cmds.py | 15 ++-- ms_agent/command/builtin/search_cmds.py | 8 +- ms_agent/command/usage.py | 10 --- ms_agent/project/session.py | 33 ++++++++ ms_agent/skills/update-config/SKILL.md | 11 ++- ms_agent/tui/app.py | 66 +++++++++++++++- tests/command/test_new_cmds.py | 61 +++++++++++++-- tests/command/test_resource_cmds.py | 3 +- tests/command/test_search_cmds.py | 3 +- tests/e2e/test_tui_webui_usability.py | 29 ++++--- tests/tui/test_tui_project.py | 82 ++++++++++++++++++++ 15 files changed, 312 insertions(+), 84 deletions(-) diff --git a/docs/tui-webui-align-e2e.md b/docs/tui-webui-align-e2e.md index cc18f608c..38dc57fba 100644 --- a/docs/tui-webui-align-e2e.md +++ b/docs/tui-webui-align-e2e.md @@ -49,7 +49,7 @@ ms-agent tui --work-dir /tmp/align-work | # | 功能 | TUI | 通过标准 | |---|---|---|---| | B1 | 列出供应商 | `/model` `/model list` | 与 WebUI 模型设置同源;key 只显示 set/missing,无明文 | -| B2 | 切换当前模型 | `/model ` 或 `/model /` | banner/当前模型变了;settings 的 default_model 更新;下一轮走新模型 | +| B2 | 切换当前模型 | `/model ` 或 `/model /` | 状态栏模型变了;settings 的 default_model 更新(之后的新对话用它);当前 session.json 的 model 也更新(回到这条对话仍是它);下一轮走新模型。不写项目 config.yaml | | B3 | 新增供应商 | `/model provider add key= url= protocol=` | WebUI 模型页出现该项 | | B4 | 改 key | `/model provider key \|clear` | json 变了;列表仍不打印明文;当前供应商会尽量立刻生效 | | B5 | 改 base_url | `/model provider url \|clear` | 同上 | @@ -188,7 +188,7 @@ ms-agent tui --work-dir /tmp/align-work 先前:`/model list` 读 `settings.json`,真正跑模型仍走 `Config.from_task(agent.yaml)`(包装里的 Qwen3-235B)。要对齐得手动 `/model openai/qwen3.7-plus`。 1. 同一 `MS_AGENT_HOME`。WebUI 设置 → 模型,默认选 `openai/qwen3.7-plus`(或当前环境真实在用的那条)并保存。 -2. 新开 TUI:`ms-agent tui --work-dir /tmp/align-work`。`/model` 只写两端共用的 `default_model`,不再给这个文件夹钉一份 `.ms_agent/config.yaml`;之后在 WebUI 改默认,下次打开 TUI(无 `--config`)应跟上。 +2. 新开 TUI:`ms-agent tui --work-dir /tmp/align-work`。`/model` 写两端共用的 `default_model`,并记下当前对话自己的模型(`session.json` 的 `model` / `model_provider`),不再给这个文件夹钉一份 `.ms_agent/config.yaml`。之后在 WebUI 改默认,下一次新建的对话跟上;用 `/resume` 回到改之前的对话,模型仍是那条对话自己的。 3. `/model`(无参数)或看 banner:当前模型应是 WebUI 刚设的那条,而不是 yaml 里的 `Qwen/Qwen3-235B-A22B-Instruct-2507`。 4. 发一句短回复(如 `ping`)。请求应打到该默认模型,不必先 `/model openai/qwen3.7-plus`。 5. (对照)`ms-agent tui --config /path/to/custom.yaml --work-dir ...`:应继续用 yaml 里写死的模型,不被 settings 改掉。 diff --git a/ms_agent/command/builtin/config_cmds.py b/ms_agent/command/builtin/config_cmds.py index d5aad0d27..1d0819018 100644 --- a/ms_agent/command/builtin/config_cmds.py +++ b/ms_agent/command/builtin/config_cmds.py @@ -3,12 +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, same_as_webui, +from ms_agent.command.usage import (arg_error, contains_secret, ledger_file, status_then_usage) CMD_MODEL = CommandDef( name='model', - description='Show, switch, or manage model providers (shared with WebUI)', + description='Show, switch, or manage model providers', category='config', ) @@ -24,7 +24,7 @@ def _model_usage() -> str: return ( 'usage:\n' ' /model show current provider + model\n' - ' /model list saved providers (same as WebUI)\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' @@ -46,7 +46,7 @@ def _model_usage() -> str: ' /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'{same_as_webui("settings.json")}\n' + f'Saved in {ledger_file("settings.json")}.\n' 'Example: /model dashscope qwen3.8-flash' ) @@ -57,9 +57,10 @@ def _model_usage() -> str: def _persist_model_to_config(config, new_model: str, service=None): """Write a work-dir ``.ms_agent/config.yaml`` pin (not used by ``/model``). - ``/model`` persists only the WebUI-shared ``default_model`` in settings.json - so a later WebUI default still wins on the next TUI launch. This helper - remains for callers that explicitly want a folder pin; that patch still + ``/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 @@ -435,7 +436,7 @@ def _provider_status_lines(mgr, *, live: bool = False, 'Providers (live /models; preview only):' ] else: - lines = ['Providers (same as WebUI):'] + lines = ['Providers:'] default = mgr.get_default_model() if default: lines.append(f'Default: {default}') @@ -777,9 +778,18 @@ def _revert_switch() -> None: 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: + 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) - content += 'Saved as the default (same as WebUI). 下一条消息使用它。' if probe is not None and probe.outcome == 'no_catalog': content += '\n' + _NO_CATALOG return CommandResult( diff --git a/ms_agent/command/builtin/instruction_cmds.py b/ms_agent/command/builtin/instruction_cmds.py index aa373c047..16be1c7eb 100644 --- a/ms_agent/command/builtin/instruction_cmds.py +++ b/ms_agent/command/builtin/instruction_cmds.py @@ -7,19 +7,18 @@ 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, same_as_webui, - status_then_usage) +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 (shared with WebUI)', + description='Show or set AGENTS.md instructions', category='config', aliases=('ins', ), ) CMD_PROFILE = CommandDef( name='profile', - description='Show or set PROFILE.md (shared with WebUI)', + description='Show or set PROFILE.md', category='config', ) @@ -34,7 +33,6 @@ def _ins_usage() -> str: 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' - f'{same_as_webui("AGENTS.md")} ' 'Takes effect on the next turn (files are read live).' ) @@ -48,7 +46,6 @@ def _profile_usage() -> str: ' /profile about \n' ' /profile about clear\n' f'Writes {ledger_file("PROFILE.md")} (Call me line + free region). ' - f'{same_as_webui("PROFILE.md")} ' 'Takes effect on the next turn.' ) diff --git a/ms_agent/command/builtin/memory_cmds.py b/ms_agent/command/builtin/memory_cmds.py index c8627fac0..89fcff008 100644 --- a/ms_agent/command/builtin/memory_cmds.py +++ b/ms_agent/command/builtin/memory_cmds.py @@ -11,7 +11,7 @@ CMD_MEMORY = CommandDef( name='memory', - description='Turn unified memory on/off (shared with WebUI)', + description='Turn memory on or off', category='config', ) @@ -24,9 +24,10 @@ ' /memory backend file|vector (global default for new folders)\n' ' /memory global backend file|vector\n' ' /memory project backend file|vector\n' - 'Project flag is what injects memory.unified_memory (same as WebUI). ' - 'Global flag is the default for new projects. Vector stays WebUI-owned; ' - 'TUI file backend writes MEMORY.md under /.ms_agent/memory/.' + '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'}) @@ -57,7 +58,7 @@ def _status_text(ctx: CommandContext) -> str: g_be = loaded.memory_backend or 'file' lines = [ f'Global default: {g_on} backend={g_be}', - '(applies when TUI/WebUI first opens a new folder)', + '(applies when a new folder is opened)', ] project = _project(ctx) if project is None: @@ -69,6 +70,12 @@ def _status_text(ctx: CommandContext) -> str: 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: @@ -87,9 +94,8 @@ async def _apply_live(ctx: CommandContext, project) -> str: kind = apply_project_memory(cfg, project) if kind == 'vector-unavailable': return ( - 'Saved vector backend for WebUI. TUI does not start vector/mem0 ' - '(no silent file fallback). Use /memory project backend file ' - 'or open the project in WebUI.') + '已保存 vector。当前会话不会启动它。' + '要用文件记忆:/memory project backend file') return '下一条消息生效。' @@ -198,9 +204,9 @@ def _cmd_backend( _pm().update(project.id, memory_backend=backend) return CommandResult( type=CommandResultType.MESSAGE, - content=( - f'Project memory backend → {backend}. ' - '下一条消息生效。Vector is WebUI-owned.'), + content=_backend_saved( + f'Project memory backend → {backend}. 下一条消息生效。', + backend), ) settings = PersonalizationSettings() @@ -208,10 +214,10 @@ def _cmd_backend( settings.save(replace(loaded, memory_backend=backend)) return CommandResult( type=CommandResultType.MESSAGE, - content=( + content=_backend_saved( f'Global memory backend default → {backend}. ' - 'New folders inherit this; this project is unchanged. ' - 'Vector is WebUI-owned.'), + 'New folders inherit this; this project is unchanged.', + backend), ) diff --git a/ms_agent/command/builtin/resource_cmds.py b/ms_agent/command/builtin/resource_cmds.py index 374c7626a..d0f9da3e6 100644 --- a/ms_agent/command/builtin/resource_cmds.py +++ b/ms_agent/command/builtin/resource_cmds.py @@ -5,18 +5,18 @@ from ms_agent.command.scope import parse_optional_scope, work_dir_of from ms_agent.command.types import (CommandContext, CommandDef, CommandResult, CommandResultType) -from ms_agent.command.usage import (arg_error, ledger_dir, same_as_webui, +from ms_agent.command.usage import (arg_error, ledger_dir, ledger_file, status_then_usage) CMD_MCP = CommandDef( name='mcp', - description='List/add/enable MCP servers (shared with WebUI mcp.json)', + description='List, add, or enable MCP servers', category='config', ) CMD_SKILL_MANAGE = CommandDef( name='skills', - description='List/add/enable skills (shared with WebUI skills.json)', + description='List, add, or enable skills', category='config', aliases=('skill-mgr', ), ) @@ -32,8 +32,9 @@ def _mcp_usage() -> str: ' /mcp json \n' ' /mcp enable|disable|remove [global|project]\n' 'Omitting scope on add writes this folder (project). ' - 'WebUI Settings → MCP is the global page.\n' - f'{same_as_webui("mcp.json")} Also project .ms_agent/mcp.json. ' + '`global` is every folder on this machine.\n' + f'This machine: {ledger_file("mcp.json")}. ' + 'This folder: /.ms_agent/mcp.json. ' 'New servers connect this session when possible; otherwise /new or restart.' ) @@ -51,14 +52,12 @@ def _skill_usage() -> str: 'otherwise this machine (global). ' 'Omit scope on enable, disable, or remove: the copy that already ' 'exists; if both exist, write global or project. ' - f'{same_as_webui("skills")} ' 'remove only deletes a managed copy, not auto-discovered skills.' ) _PROJECT_MCP_NOT_ON_SETTINGS = ( - 'Not visible on WebUI Settings → MCP (that page is global). ' - 'Add with `global` to show it there.' + 'Saved for this folder. Use global to save it for every folder.' ) diff --git a/ms_agent/command/builtin/search_cmds.py b/ms_agent/command/builtin/search_cmds.py index e126cfd2c..d83047189 100644 --- a/ms_agent/command/builtin/search_cmds.py +++ b/ms_agent/command/builtin/search_cmds.py @@ -4,11 +4,11 @@ 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, same_as_webui, status_then_usage +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 (shared with WebUI)', + description='Show or set the web-search engine', category='config', ) @@ -22,7 +22,7 @@ def _usage() -> str: ' /search key \n' ' /search key clear\n' ' /search enable|disable\n' - f'{same_as_webui("settings.json")} ' + f'Saved in {ledger_file("settings.json")}. ' 'Takes effect on the next turn, or /new if search was already connected.' ) @@ -84,7 +84,7 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: rest = parts[1:] if action == 'list': - lines = ['Search engines (shared with WebUI):'] + lines = ['Search engines:'] current = mgr.get().engine for row in mgr.list_engines(): mark = '*' if row['id'] == current else ' ' diff --git a/ms_agent/command/usage.py b/ms_agent/command/usage.py index 81f30aaf4..b7b7e8335 100644 --- a/ms_agent/command/usage.py +++ b/ms_agent/command/usage.py @@ -21,16 +21,6 @@ def ledger_file(rel: str) -> str: return f'{ledger_dir()}/{rel}' -def same_as_webui(rel: str = '') -> str: - """Shared-ledger line: live path, then the env var that produced it. - - The path is not a secret. Showing only ``MS_AGENT_HOME`` makes a person - expand it themselves; showing only ``~/.ms_agent`` lies when the env is set. - """ - loc = ledger_file(rel) if rel else ledger_dir() - return f'Same as WebUI: {loc} (from MS_AGENT_HOME).' - - def status_then_usage(status: str, usage: str) -> str: """Bare ``/cmd``: a short identity card, then the usage sheet. diff --git a/ms_agent/project/session.py b/ms_agent/project/session.py index 0cd25f6cb..07d9d0338 100644 --- a/ms_agent/project/session.py +++ b/ms_agent/project/session.py @@ -197,3 +197,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/skills/update-config/SKILL.md b/ms_agent/skills/update-config/SKILL.md index 796c82d93..2ed3a14d9 100644 --- a/ms_agent/skills/update-config/SKILL.md +++ b/ms_agent/skills/update-config/SKILL.md @@ -16,8 +16,8 @@ before writing. Merge; never replace a whole config file. | What | This folder (project) | Every project (global) | |------|------------------------|------------------------| | Long-term memory file | `{memory_md}` | Memory is per-project; there is no global MEMORY.md | -| Memory **flag** (what actually injects it) | TUI `/memory on`, or the WebUI project memory toggle | `/memory global on` only sets the default for **newly opened** folders | -| MCP servers | `{project_mcp}` | `{global_mcp}` (WebUI Settings → MCP) | +| Memory **flag** (what actually injects it) | `/memory on` | `/memory global on` only sets the default for **newly opened** folders | +| MCP servers | `{project_mcp}` | `{global_mcp}` | A server in the global file is not the same as one in the project file. Ask if scope is unclear. @@ -26,9 +26,8 @@ if scope is unclear. 1. Facts live in `{memory_md}`. 2. Creating that file does **nothing** until the **project** memory flag is - on. TUI: `/memory on` — the next user message in this session picks it - up. WebUI: the project memory toggle. Do not tell the user to `/new` - first. + on. `/memory on` — the next user message in this session picks it + up. Do not tell the user to `/new` first. 3. **Do not** add `memory.unified_memory` (or any memory block) to `{work}/.ms_agent/config.yaml` or to agent.yaml. That is not how this product enables memory. @@ -38,7 +37,7 @@ MEMORY.md directly only when the user asks to inspect or rewrite it. ## MCP servers -TUI slash commands write the same files WebUI uses: +Slash commands write these files: - This folder: `/mcp add NAME project url=...` (omitting scope writes project) - Every project: `/mcp add NAME global url=...` diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index 39e1e6b71..fb1f823cc 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -464,6 +464,10 @@ 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 @@ -481,6 +485,64 @@ def _apply_session(self, session, resume: bool = False) -> None: 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() @@ -702,7 +764,7 @@ async def _serve(self) -> 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._sm.create(model=self.state.model or None) + 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') @@ -785,7 +847,7 @@ async def _serve(self) -> None: break # user quit kind = switch[0] if kind == 'new': - self.session = self._sm.create(model=self.state.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') diff --git a/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py index eb81c4e97..0759c1760 100644 --- a/tests/command/test_new_cmds.py +++ b/tests/command/test_new_cmds.py @@ -391,7 +391,9 @@ async def test_switch_model_persists_to_settings_not_project_patch( result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE - assert 'Saved as the default' 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. @@ -416,11 +418,58 @@ async def test_switch_model_no_source_file(self, tmp_path, monkeypatch): _catalog_ok(monkeypatch, ['gpt-4o']) result = await router.dispatch(ctx) assert result.type == CommandResultType.MUTATE_STATE - assert 'Saved as the default' 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 @@ -431,7 +480,8 @@ async def test_model_list_reads_settings(self, tmp_path): ctx = make_ctx('/model list', runtime=MockRuntime()) result = await router.dispatch(ctx) assert 'Default: acme/a-1' in result.content - assert 'same as WebUI' 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): @@ -619,9 +669,10 @@ async def test_help_usage_example_is_dashscope_qwen(self): 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 'Same as WebUI:' in result.content + assert 'Saved in ' in result.content assert 'settings.json' in result.content - assert 'MS_AGENT_HOME' 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 diff --git a/tests/command/test_resource_cmds.py b/tests/command/test_resource_cmds.py index eef782789..e2cf35943 100644 --- a/tests/command/test_resource_cmds.py +++ b/tests/command/test_resource_cmds.py @@ -110,7 +110,8 @@ async def test_add_project_writes_mcp_json(self, tmp_path, isolate_home): make_ctx('/mcp add local command=npx', runtime)) assert 'Added local' in result.content assert '(project)' in result.content - assert 'Settings' 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' diff --git a/tests/command/test_search_cmds.py b/tests/command/test_search_cmds.py index bd6f919bb..04ba86b4c 100644 --- a/tests/command/test_search_cmds.py +++ b/tests/command/test_search_cmds.py @@ -43,7 +43,8 @@ async def test_default_status(self, isolate_home): assert 'Engine: tavily' in result.content assert '/search engine' in result.content assert str(isolate_home) in result.content - assert 'MS_AGENT_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): diff --git a/tests/e2e/test_tui_webui_usability.py b/tests/e2e/test_tui_webui_usability.py index 83b83f249..ebc4de380 100644 --- a/tests/e2e/test_tui_webui_usability.py +++ b/tests/e2e/test_tui_webui_usability.py @@ -18,23 +18,18 @@ def _assert_live_home(content: str, home) -> None: - """Help shows the resolved path *and* that MS_AGENT_HOME produced it.""" + """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 'MS_AGENT_HOME' in content, ( - 'help should say the path comes from MS_AGENT_HOME') + assert 'WebUI' not in content + assert 'MS_AGENT_HOME' not in content class TestHelpTextLiesAboutHome: - """Slash help must not hardcode ``~/.ms_agent`` when MS_AGENT_HOME is set. - - Testers are told never to use ~/.ms_agent. Showing only the env var name - also makes them expand it themselves — print the live path, then say - which env produced it. - """ + """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): @@ -105,9 +100,9 @@ def test_skills_list_without_runtime_shows_imported_ids( class TestDefaultScopeFootgun: """``/mcp add`` omits to this folder (project) and says so. - WebUI Settings → MCP is the global page, so the success line must - mention that. ``/skills add`` and ``/skills enable`` share one default: - project when a work dir is open, otherwise global. + ``global`` is every folder on this machine. ``/skills add`` and + ``/skills enable`` share one default: project when a work dir is open, + otherwise global. """ @pytest.mark.usability @@ -137,9 +132,9 @@ def test_mcp_add_without_scope_warns_about_settings_page( '/mcp add docs url=https://example.invalid/mcp', runtime_for(work_dir)) lowered = result.content.lower() - assert 'global' in lowered and ( - 'settings' in lowered or 'not visible' in lowered - or 'project scope' in lowered) + assert 'this folder' in lowered + assert 'global' in lowered + assert 'webui' not in lowered @pytest.mark.usability def test_skills_add_and_enable_share_project_default_when_work_dir( @@ -204,7 +199,9 @@ def test_switch_does_not_write_a_project_patch( rt) run_slash('/model catalog add acme a-1', rt) result = run_slash('/model acme/a-1', rt) - assert 'Saved as the default' in result.content + 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() diff --git a/tests/tui/test_tui_project.py b/tests/tui/test_tui_project.py index 4acadab8b..4797492ef 100644 --- a/tests/tui/test_tui_project.py +++ b/tests/tui/test_tui_project.py @@ -81,6 +81,88 @@ def test_prepare_config_merges_personalization(tmp_path, monkeypatch): 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)) From 461bc3c0a0b48a9785a348119bce63c7d516cf31 Mon Sep 17 00:00:00 2001 From: suluyan Date: Wed, 14 Oct 2026 17:16:47 +0800 Subject: [PATCH 06/21] fix(tui): correct scope, secrets, and failure notices Keep the two skill trees, default a new slash skill to this machine, and show one MCP with /mcp get. Mask credentials in /config, and stop sending a saved key when only the model URL changes. Tell the user when memory or an MCP server actually fails, and drop the bundled update-config skill. Co-authored-by: Cursor --- MANIFEST.in | 2 +- ms_agent/agent/llm_agent.py | 34 ++- ms_agent/agent_hub/_secrets.py | 8 +- ms_agent/command/builtin/config_cmds.py | 103 ++++---- ms_agent/command/builtin/memory_cmds.py | 20 +- ms_agent/command/builtin/resource_cmds.py | 210 +++++++++++++--- ms_agent/command/builtin/search_cmds.py | 30 ++- ms_agent/command/scope.py | 20 +- ms_agent/mcp/runtime.py | 45 +++- ms_agent/memory/default_memory.py | 7 +- .../memory/unified/backends/file_based.py | 5 +- ms_agent/memory/unified/orchestrator.py | 18 +- .../memory/unified/storage/file_storage.py | 11 +- ms_agent/memory/unified/user_notice.py | 16 ++ .../session/strategies/summary_compactor.py | 3 +- ms_agent/skill/harness.py | 10 +- ms_agent/skill/prompt_injector.py | 13 +- ms_agent/skills/update-config/SKILL.md | 89 ------- ms_agent/tools/search/websearch_tool.py | 20 ++ ms_agent/tools/tool_manager.py | 21 +- ms_agent/tui/app.py | 25 +- ms_agent/tui/live_config.py | 148 +++++++++++- tests/agent_hub/test_secrets.py | 24 ++ tests/command/test_memory_cmds.py | 2 +- tests/command/test_new_cmds.py | 169 +++++++++++++ tests/command/test_resource_cmds.py | 182 +++++++++++++- tests/command/test_search_cmds.py | 2 + tests/e2e/test_tui_webui_usability.py | 18 +- tests/e2e/test_update_config_boot.py | 227 ------------------ tests/llm/test_model_discovery.py | 15 ++ tests/mcp/test_mcp_runtime.py | 161 +++++++++++++ tests/memory/test_file_storage_notice.py | 56 ++++- tests/memory/test_orchestrator_scheduling.py | 42 +++- .../test_workspace_internals_hint.py | 3 +- tests/skill/test_update_notice.py | 3 +- tests/skills/test_update_config.py | 109 ++------- tests/tui/test_live_config.py | 121 ++++++++++ tests/tui/test_tui_config_align.py | 115 ++++++++- 38 files changed, 1532 insertions(+), 575 deletions(-) delete mode 100644 ms_agent/skills/update-config/SKILL.md delete mode 100644 tests/e2e/test_update_config_boot.py diff --git a/MANIFEST.in b/MANIFEST.in index 55e4bfe95..33fc54410 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,7 +7,7 @@ recursive-include ms_agent/ *.yaml # agent_hub cross-framework conversion templates (markdown, not yaml) recursive-include ms_agent/agent_hub/default_configs * -# Bundled harness skills (update-config, …) +# Bundled skills directory (harness skills are not shipped right now). recursive-include ms_agent/skills * # Include projects diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index d28577410..b10ce27c6 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -1042,7 +1042,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, @@ -2413,6 +2413,38 @@ def _emit_user_notice(self, text: str) -> None: 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) 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'(? str | None: return None head, rest = text.split('/', 1) head, rest = head.strip(), rest.strip() - if not head or not rest or head != head.lower(): + 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 @@ -373,11 +379,10 @@ def _runtime_api_key(config, pid: str) -> str: from ms_agent.llm.credentials import CredentialResolver from ms_agent.llm.spec import get_registry spec = get_registry().get(pid) - llm = getattr(config, 'llm', None) - if spec is None or llm is None: + if spec is None or getattr(config, 'llm', None) is None: return '' try: - return str(CredentialResolver.resolve_api_key(spec, llm) or '').strip() + return str(CredentialResolver.resolve_api_key(spec, config) or '').strip() except Exception: return '' @@ -486,28 +491,6 @@ def _install_rebuilt_llm(target, rebuilt) -> bool: return False -def _rebuild_llm(ctx: CommandContext) -> bool: - if not ctx.runtime or not ctx.runtime.llm: - return False - from ms_agent.llm import LLM - target = ctx.runtime.llm - config = target.config - try: - rebuilt = LLM.from_config(config) - except Exception: # noqa: BLE001 - missing key / bad endpoint - rebuilt = None - if rebuilt is None: - return False - if _install_rebuilt_llm(target, rebuilt): - return True - ctx.runtime.llm = rebuilt - extra = ctx.extra or {} - agent = extra.get('agent') - if agent is not None: - agent.llm = rebuilt - return True - - def _push_provider_to_runtime(ctx: CommandContext, provider_id: str) -> str: if not ctx.runtime or not ctx.runtime.llm: return '' @@ -520,7 +503,7 @@ def _push_provider_to_runtime(ctx: CommandContext, provider_id: str) -> str: f' Switch with /model {provider_id} to use this ' 'provider live.') TuiApp._apply_provider_credentials(config, overwrite=True) - return ' 下一条消息生效。' + return '' def _split_kv(tokens: list[str]) -> tuple[list[str], dict[str, str]]: @@ -609,14 +592,36 @@ def _secret_model_error(ctx: CommandContext) -> CommandResult: _NO_CATALOG = '已保存。这个地址没有返回模型列表,下一次对话才会确认模型名。' +_UNREACHABLE = '这个地址现在连不上,密钥还没向供应商确认。' -def _probe_saved_provider(pid: str): - """Catalog probe for the provider as currently saved. None without key+url.""" +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) @@ -673,18 +678,21 @@ def _probe_reject_text(pid: str, outcome: str, fields: set[str], def _gate_provider_write(pid: str, before: dict, fields: set[str]) -> tuple[str | None, str]: - """Probe after a key or URL write. + """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. ``saved_note`` is the no-catalog sentence. + the write was rolled back. 401/403 rolls back. A connection failure keeps + the write and returns the unreachable note. """ - if not fields: + 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), '' @@ -756,7 +764,7 @@ def _revert_switch() -> None: ) from ms_agent.llm.model_discovery import nearby_model_ids - probe = _probe_saved_provider(pid) + probe = _probe_saved_provider(pid, config) if probe is not None and probe.outcome in ('auth', 'unreachable'): _revert_switch() return CommandResult( @@ -1036,24 +1044,37 @@ def _cmd_model_catalog(ctx: CommandContext, def _mask_config_secrets(node) -> None: - """Blank secret-shaped keys and entire ``headers`` / ``env`` bags. + """Blank secret-shaped keys, and secret values inside ``headers`` / ``env``. - Same vocabulary as convert upload (``is_secret_key``), so ``/config`` - masks search keys and MCP header tokens the way it already masks - ``llm`` API keys. + 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): - node[key] = { - inner: '***' if inner_val not in (None, '') else inner_val - for inner, inner_val in val.items() - } + 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): diff --git a/ms_agent/command/builtin/memory_cmds.py b/ms_agent/command/builtin/memory_cmds.py index 89fcff008..a5614872d 100644 --- a/ms_agent/command/builtin/memory_cmds.py +++ b/ms_agent/command/builtin/memory_cmds.py @@ -85,18 +85,26 @@ def _parse_bool(token: str) -> bool | None: 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: from ms_agent.personalization.memory_apply import apply_project_memory + _arm_memory(ctx) agent = ctx.runtime cfg = getattr(agent, 'config', None) if agent is not None else None if cfg is None: - return '已保存。下一条消息生效。' + return '已保存。' kind = apply_project_memory(cfg, project) if kind == 'vector-unavailable': return ( '已保存 vector。当前会话不会启动它。' '要用文件记忆:/memory project backend file') - return '下一条消息生效。' + return '' async def cmd_memory(ctx: CommandContext) -> CommandResult: @@ -167,9 +175,12 @@ async def _cmd_toggle(ctx: CommandContext, action: str, 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=f'Project memory → {state}. {note}', + content=content, ) @@ -202,10 +213,11 @@ def _cmd_backend( 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}. 下一条消息生效。', + f'Project memory backend → {backend}.', backend), ) diff --git a/ms_agent/command/builtin/resource_cmds.py b/ms_agent/command/builtin/resource_cmds.py index d0f9da3e6..0f010fc9d 100644 --- a/ms_agent/command/builtin/resource_cmds.py +++ b/ms_agent/command/builtin/resource_cmds.py @@ -2,7 +2,8 @@ from __future__ import annotations from ms_agent.command.router import CommandRouter -from ms_agent.command.scope import parse_optional_scope, work_dir_of +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, @@ -26,6 +27,7 @@ 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' @@ -34,8 +36,7 @@ def _mcp_usage() -> str: '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. ' - 'New servers connect this session when possible; otherwise /new or restart.' + 'This folder: /.ms_agent/mcp.json.' ) @@ -48,10 +49,10 @@ def _skill_usage() -> str: ' /skills remove [global|project]\n' f'Directory drop-in: global → {ledger_dir()}/skills, ' 'project → /.ms_agent/skills. ' - 'Omit scope on add: this folder (project) when a work dir is open, ' - 'otherwise this machine (global). ' + '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, write global or project. ' + 'exists. If both exist, the TUI menu asks which one. ' 'remove only deletes a managed copy, not auto-discovered skills.' ) @@ -139,6 +140,110 @@ def _mcp_list_text(mgr, scope: str) -> str: 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, + ) + return CommandResult( + type=CommandResultType.MESSAGE, + content='\n\n'.join(blocks), + ) + + async def cmd_mcp(ctx: CommandContext) -> CommandResult: arg = (ctx.args or '').strip() home, work = _home_work(ctx) @@ -160,26 +265,45 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: tokens = parts[1:] if action == 'list': - scope, _ = _parse_scope(tokens, default='merged') + 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=_mcp_list_text(mgr, scope), + content=note + _mcp_list_text(mgr, scope), ) + 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]) - note = await _reload_mcp(ctx) + await _reload_mcp(ctx) return CommandResult( type=CommandResultType.MESSAGE, - content=f'Imported {n} server(s). {note}', + content=f'Imported {n} server(s).', ) if action in ('enable', 'disable', 'remove'): @@ -202,10 +326,10 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: except KeyError as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - note = await _reload_mcp(ctx) + await _reload_mcp(ctx) return CommandResult( type=CommandResultType.MESSAGE, - content=f'{action} {name} ({scope}). {note}', + content=f'{action} {name} ({scope}).', ) if action == 'add': @@ -226,8 +350,8 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: content='Need command= or url=.', ) mgr.add(name, _stdio_fields(fields), scope=scope) - note = await _reload_mcp(ctx) - lines = [f'Added {name} ({scope}). {note}'] + await _reload_mcp(ctx) + lines = [f'Added {name} ({scope}).'] if scope == 'project' and not explicit: lines.append(_PROJECT_MCP_NOT_ON_SETTINGS) return CommandResult( @@ -252,14 +376,14 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: except KeyError as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - note = await _reload_mcp(ctx) + await _reload_mcp(ctx) return CommandResult( type=CommandResultType.MESSAGE, - content=f'Updated {name} ({scope}). {note}', + content=f'Updated {name} ({scope}).', ) return arg_error( - '/mcp list|add|update|json|enable|disable|remove ...', + '/mcp list|get|add|update|json|enable|disable|remove ...', reason=f'Unknown mcp action {action!r}', note='Type /mcp for all commands', ctx=ctx, @@ -267,9 +391,9 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: async def _reload_mcp(ctx: CommandContext) -> str: - """Ledger is already written. The live runtime applies it next message.""" + """Ledger is already written. The next user message applies it.""" del ctx - return '下一条消息生效。' + return '' async def cmd_skills(ctx: CommandContext) -> CommandResult: @@ -303,7 +427,7 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: return _list_skills_from_disk(mgr, work) if action in ('enable', 'disable'): - resolved = _resolve_skill_scope( + resolved = await _resolve_skill_scope( tokens, work, syntax=f'/skills {action} [global|project]', @@ -323,11 +447,12 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: ) if action == 'add': - resolved = _resolve_skill_scope( + resolved = await _resolve_skill_scope( tokens, work, syntax='/skills add [global|project]', - ctx=ctx) + ctx=ctx, + action='add') if isinstance(resolved, CommandResult): return resolved scope, rest = resolved @@ -345,13 +470,11 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: _resync_skills(ctx, mgr, home, work) return CommandResult( type=CommandResultType.MESSAGE, - content=( - 'Imported: ' + ', '.join(names) - + f' ({scope}). Available this session.'), + content='Imported: ' + ', '.join(names) + f' ({scope}).', ) if action == 'remove': - resolved = _resolve_skill_scope( + resolved = await _resolve_skill_scope( tokens, work, syntax='/skills remove [global|project]', @@ -382,10 +505,6 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: ) -def _default_skill_scope(work: str | None) -> str: - return 'project' if work else 'global' - - def _managed_skill_scopes(mgr, skill_id: str, work: str | None) -> list[str]: """Scopes whose live tree contains ``/SKILL.md``.""" found: list[str] = [] @@ -427,7 +546,7 @@ def _pick_existing_skill_scope(mgr, skill_id: str, work: str | None, return None -def _resolve_skill_scope( +async def _resolve_skill_scope( tokens: list[str], work: str | None, *, @@ -446,16 +565,35 @@ def _resolve_skill_scope( skill_id = ' '.join(rest) picked = _pick_existing_skill_scope(mgr, skill_id, work, action) if picked == 'both': - return arg_error( - syntax, + # 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.', - ctx=ctx, ) - if picked: + 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 = _default_skill_scope(work) + scope = 'global' miss = _need_work(scope, work) if miss: return miss diff --git a/ms_agent/command/builtin/search_cmds.py b/ms_agent/command/builtin/search_cmds.py index d83047189..88844b063 100644 --- a/ms_agent/command/builtin/search_cmds.py +++ b/ms_agent/command/builtin/search_cmds.py @@ -22,8 +22,7 @@ def _usage() -> str: ' /search key \n' ' /search key clear\n' ' /search enable|disable\n' - f'Saved in {ledger_file("settings.json")}. ' - 'Takes effect on the next turn, or /new if search was already connected.' + f'Saved in {ledger_file("settings.json")}.' ) @@ -52,18 +51,17 @@ def _status_text(mgr) -> str: return '\n'.join(lines) -def _apply_runtime(ctx: CommandContext, mgr) -> str: +def _apply_runtime(ctx: CommandContext, mgr) -> None: + """Write the saved block onto the live config. The next message reconnects.""" agent = ctx.runtime cfg = getattr(agent, 'config', None) if agent is not None else None if cfg is None: - return 'Takes effect on /new or restart.' + return try: from omegaconf import OmegaConf - OmegaConf.update( - cfg, 'tools.web_search', mgr.raw_block(), merge=True) - return 'Next turn uses this setting (or /new if search was already connected).' + OmegaConf.update(cfg, 'tools.web_search', mgr.raw_block(), merge=True) except Exception: - return 'Saved; /new or restart to apply in this session.' + return async def cmd_search(ctx: CommandContext) -> CommandResult: @@ -98,17 +96,17 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: if action in ('enable', 'on'): mgr.set_enabled(True) - note = _apply_runtime(ctx, mgr) + _apply_runtime(ctx, mgr) return CommandResult( type=CommandResultType.MESSAGE, - content=f'Search enabled. {note}', + content='Search enabled.', ) if action in ('disable', 'off'): mgr.set_enabled(False) - note = _apply_runtime(ctx, mgr) + _apply_runtime(ctx, mgr) return CommandResult( type=CommandResultType.MESSAGE, - content=f'Search disabled. {note}', + content='Search disabled.', ) if action == 'engine': @@ -120,10 +118,10 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: except ValueError as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - note = _apply_runtime(ctx, mgr) + _apply_runtime(ctx, mgr) return CommandResult( type=CommandResultType.MESSAGE, - content=f'Engine → {cur.engine}. {note}', + content=f'Engine → {cur.engine}.', ) if action == 'key': @@ -136,11 +134,11 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: except ValueError as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - note = _apply_runtime(ctx, mgr) + _apply_runtime(ctx, mgr) verb = 'cleared' if clear else 'saved' return CommandResult( type=CommandResultType.MESSAGE, - content=f'API key {verb} for {mgr.get().engine}. {note}', + content=f'API key {verb} for {mgr.get().engine}.', ) return arg_error( diff --git a/ms_agent/command/scope.py b/ms_agent/command/scope.py index b6e2d9e5a..c65eec709 100644 --- a/ms_agent/command/scope.py +++ b/ms_agent/command/scope.py @@ -17,10 +17,10 @@ SCOPE_TOKENS = frozenset({'global', 'project'}) -# (scope, label) — labels match the Claude plugin install picker tone. +# (scope, label). The TUI arrow menu shows these two rows. SCOPE_CHOICES: tuple[tuple[str, str], ...] = ( - ('global', 'This machine (global)'), - ('project', 'This folder (project)'), + ('global', '这台机器'), + ('project', '这个文件夹'), ) @@ -86,12 +86,15 @@ async def pick_scope( 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. Tests may inject - ``extra['choose_scope']`` (sync or async callable) returning an index, - a scope id, or a label. + 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: @@ -103,6 +106,7 @@ async def pick_scope( 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( @@ -111,7 +115,9 @@ async def pick_scope( scope = _coerce_pick(picked, rows) if scope is not None: return scope - return arg_error(syntax, note=note, ctx=ctx) + if silent_default in ('global', 'project'): + return silent_default + return arg_error(syntax, reason=reason, note=note, ctx=ctx) async def _invoke_test_picker( diff --git a/ms_agent/mcp/runtime.py b/ms_agent/mcp/runtime.py index b1aabfc3b..2dc6d77de 100644 --- a/ms_agent/mcp/runtime.py +++ b/ms_agent/mcp/runtime.py @@ -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,20 @@ def is_connection_error(exc: BaseException) -> bool: return classify_mcp_failure(exc) in ('transient', 'hard') +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.""" @@ -334,6 +369,12 @@ async def apply_config(self, 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) @@ -392,12 +433,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 e83504938..22a18fc59 100644 --- a/ms_agent/memory/unified/backends/file_based.py +++ b/ms_agent/memory/unified/backends/file_based.py @@ -267,7 +267,10 @@ async def handle_tool_call( 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 89d6a8e19..78df13053 100644 --- a/ms_agent/memory/unified/orchestrator.py +++ b/ms_agent/memory/unified/orchestrator.py @@ -294,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) @@ -303,20 +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: - from ms_agent.memory.unified.user_notice import DISK_NOTICE - self.user_notice = DISK_NOTICE - logger.error(DISK_NOTICE) + 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.debug('[orchestrator] memory ingest failed, nothing was ' - 'persisted for this turn: %s: %s', - 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: diff --git a/ms_agent/memory/unified/storage/file_storage.py b/ms_agent/memory/unified/storage/file_storage.py index 6c35743fb..b80ddb892 100644 --- a/ms_agent/memory/unified/storage/file_storage.py +++ b/ms_agent/memory/unified/storage/file_storage.py @@ -104,7 +104,7 @@ def _add_entry(self, content: str) -> bool: 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.debug( + logger.warning( '[file_storage] MEMORY.md would exceed char limit ' '(%s > %s), skipping add', len(new_content), self.char_limit) return False @@ -126,12 +126,13 @@ def replace_entry(self, old_content: str, new_content: str) -> bool: if len(updated) > self.char_limit: from ms_agent.memory.unified.user_notice import CHAR_LIMIT_NOTICE self.user_notice = CHAR_LIMIT_NOTICE - logger.debug('[file_storage] Replace would exceed char limit') + 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() @@ -198,9 +199,9 @@ def _read(self) -> str: def _write(self, content: str) -> None: try: atomic_write_text(self.memory_path, content) - except OSError: - from ms_agent.memory.unified.user_notice import DISK_NOTICE - self.user_notice = DISK_NOTICE + 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 diff --git a/ms_agent/memory/unified/user_notice.py b/ms_agent/memory/unified/user_notice.py index 56e7f201f..1d5b21876 100644 --- a/ms_agent/memory/unified/user_notice.py +++ b/ms_agent/memory/unified/user_notice.py @@ -4,7 +4,23 @@ 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/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 index 8dd18c093..2a43559fe 100644 --- a/ms_agent/skill/harness.py +++ b/ms_agent/skill/harness.py @@ -1,18 +1,16 @@ # Copyright (c) ModelScope Contributors. All rights reserved. """Live path substitution for bundled harness skills. -Claude Code keeps settings.json / MCP playbooks out of the always-on system -prompt: the Skill listing is name + a short when-to-use description, and the -full guide (with live paths) is produced only when the skill is invoked. -``update-config`` follows that pattern — placeholders in SKILL.md are filled -here at ``skill_view`` / slash-expand time, not at prompt assembly. +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({'update-config'}) +HARNESS_SKILL_IDS: frozenset[str] = frozenset() def harness_placeholders(config: Any = None) -> Dict[str, str]: 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/skills/update-config/SKILL.md b/ms_agent/skills/update-config/SKILL.md deleted file mode 100644 index 2ed3a14d9..000000000 --- a/ms_agent/skills/update-config/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: update-config -description: "Use this skill to configure the ms-agent harness: long-term memory (MEMORY.md, /memory on), MCP servers (mcp.json, /mcp add), and global vs project scope. Trigger on 配置记忆, 添加 MCP, 怎么开 memory, turn memory on, add an MCP server, or any ask about where config lives. Do not put memory settings in config.yaml — the project memory flag is what injects it. Not for writing the user's application code." ---- - -# Update Config - -Configure **ms-agent itself** (memory, MCP, global vs project). This is the -harness, not the user's application. - -Live paths on this machine are already filled in below. Read the target file -before writing. Merge; never replace a whole config file. - -## Two scopes - -| What | This folder (project) | Every project (global) | -|------|------------------------|------------------------| -| Long-term memory file | `{memory_md}` | Memory is per-project; there is no global MEMORY.md | -| Memory **flag** (what actually injects it) | `/memory on` | `/memory global on` only sets the default for **newly opened** folders | -| MCP servers | `{project_mcp}` | `{global_mcp}` | - -A server in the global file is not the same as one in the project file. Ask -if scope is unclear. - -## Long-term memory - -1. Facts live in `{memory_md}`. -2. Creating that file does **nothing** until the **project** memory flag is - on. `/memory on` — the next user message in this session picks it - up. Do not tell the user to `/new` first. -3. **Do not** add `memory.unified_memory` (or any memory block) to - `{work}/.ms_agent/config.yaml` or to agent.yaml. That is not how this - product enables memory. - -Once the flag is on, prefer the session's memory tools for new facts; edit -MEMORY.md directly only when the user asks to inspect or rewrite it. - -## MCP servers - -Slash commands write these files: - -- This folder: `/mcp add NAME project url=...` (omitting scope writes project) -- Every project: `/mcp add NAME global url=...` - -If you edit the file yourself, read it first and merge into `mcpServers`. - -HTTP: - -```json -{ - "mcpServers": { - "NAME": { - "type": "streamable_http", - "url": "https://example.com/mcp" - } - } -} -``` - -stdio: - -```json -{ - "mcpServers": { - "NAME": { - "command": "npx", - "args": ["-y", "some-mcp-server"] - } - } -} -``` - -Edits apply on the next user message in this session. Do not tell the -user to `/new` or restart for a server that was just saved. - -## Workflow - -1. Clarify global vs project if ambiguous. -2. Read the existing file (it may not exist yet — that is fine). -3. Merge the change; keep unrelated keys. -4. For memory, also tell the user the **project flag** must be on. -5. Confirm the path you wrote and the scope. - -## Common mistakes - -1. Teaching `config.yaml` / `memory.unified_memory` as the way to turn memory on. -2. Writing MEMORY.md without enabling the project flag. -3. Putting a project MCP server in `{global_mcp}` (or the reverse). -4. Replacing `mcp.json` instead of merging `mcpServers`. 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/tool_manager.py b/ms_agent/tools/tool_manager.py index 600dc9376..f2c36d781 100644 --- a/ms_agent/tools/tool_manager.py +++ b/ms_agent/tools/tool_manager.py @@ -38,6 +38,23 @@ 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) + 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)) @@ -831,7 +848,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 fb1f823cc..c8fc1a37d 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -53,8 +53,8 @@ 'mcp': False, }, }, - # Non-empty so LLMAgent.prepare_skills runs and loads bundled skills - # (update-config, …). An empty ``{}`` is falsy under OmegaConf. + # 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 @@ -780,7 +780,26 @@ async def _serve(self) -> None: except EOFError: break # Ctrl-D at the prompt exits except asyncio.CancelledError: - break # generator/teardown cancel on /quit — not a crash + 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 diff --git a/ms_agent/tui/live_config.py b/ms_agent/tui/live_config.py index cf149fe21..a644cf89c 100644 --- a/ms_agent/tui/live_config.py +++ b/ms_agent/tui/live_config.py @@ -71,6 +71,128 @@ async def _sync_llm(agent) -> None: set_llm(agent.llm) +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} 还是连不上。') + + async def _sync_mcp(agent) -> None: runtime = getattr(agent, 'mcp_runtime', None) if runtime is None: @@ -90,13 +212,26 @@ async def _sync_mcp(agent) -> None: for name, entry in loaded.items(): servers.setdefault(name, entry) token = _token(servers) - if getattr(agent, '_mcp_fp', None) == token: + 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) + if pending: + _say_mcp_still_down(agent, pending) + agent._mcp_reconnect_names = set() + agent._mcp_fp = token return + still = [] + for name in pending: + 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 @@ -141,6 +276,8 @@ def _unregister_memory(agent) -> None: 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 @@ -154,6 +291,8 @@ async def _sync_memory(agent) -> None: 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) @@ -167,6 +306,11 @@ async def _sync_memory(agent) -> None: 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: @@ -178,8 +322,10 @@ async def apply_live_config(agent, messages=None) -> None: 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: 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/command/test_memory_cmds.py b/tests/command/test_memory_cmds.py index 365f60280..71e7d5c7b 100644 --- a/tests/command/test_memory_cmds.py +++ b/tests/command/test_memory_cmds.py @@ -76,7 +76,7 @@ async def test_project_toggle_writes_meta_and_config( node = OmegaConf.select(runtime.config, 'memory.unified_memory') assert node is not None assert node.storage.backend == 'file' - assert '下一条消息生效' in result.content + assert '下一条消息生效' not in result.content assert runtime.memory_tools == [] @pytest.mark.asyncio diff --git a/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py index 0759c1760..788cf2283 100644 --- a/tests/command/test_new_cmds.py +++ b/tests/command/test_new_cmds.py @@ -713,6 +713,40 @@ async def test_unknown_provider_prefix_is_rejected(self, tmp_path): 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() @@ -784,6 +818,10 @@ async def test_masks_search_keys_and_mcp_headers(self): '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': { @@ -795,6 +833,11 @@ async def test_masks_search_keys_and_mcp_headers(self): 'url': 'https://example.invalid/mcp', 'headers': { 'Authorization': 'Bearer THIS-IS-MY-TOKEN', + 'Content-Type': 'application/json', + 'X-Custom': 'keep-me', + }, + 'env': { + 'PATH': '/usr/bin', }, }, }, @@ -808,6 +851,14 @@ async def test_masks_search_keys_and_mcp_headers(self): 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: @@ -1004,6 +1055,124 @@ async def test_auth_rolls_back_the_new_key(self, tmp_path, monkeypatch): 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') diff --git a/tests/command/test_resource_cmds.py b/tests/command/test_resource_cmds.py index e2cf35943..8a802fd39 100644 --- a/tests/command/test_resource_cmds.py +++ b/tests/command/test_resource_cmds.py @@ -152,6 +152,97 @@ async def test_list_without_work_dir_does_not_crash(self, isolate_home): 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_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() @@ -233,7 +324,7 @@ def reload_all(self): assert 'demo' in data.get('disabled', []) @pytest.mark.asyncio - async def test_add_without_scope_uses_project_when_work_dir( + async def test_add_without_scope_lands_on_this_machine( self, tmp_path, isolate_home): src = tmp_path / 'ask-skill' src.mkdir() @@ -244,6 +335,31 @@ async def test_add_without_scope_uses_project_when_work_dir( 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() @@ -261,7 +377,7 @@ async def test_add_without_work_dir_defaults_global( assert (dest / 'solo-skill' / 'SKILL.md').is_file() @pytest.mark.asyncio - async def test_enable_without_scope_uses_same_default_as_add( + async def test_disable_without_scope_follows_the_added_copy( self, tmp_path, isolate_home): src = tmp_path / 'only-here' src.mkdir() @@ -275,11 +391,11 @@ async def test_enable_without_scope_uses_same_default_as_add( make_ctx(f'/skills add {src}', runtime)) result = await router.dispatch( make_ctx('/skills disable only-here', runtime)) - assert 'disable only-here (project)' in result.content - proj = SkillsConfigManager(str(isolate_home)).load_project(str(work)) - assert 'only-here' in proj.get('disabled', []) + assert 'disable only-here (global)' in result.content glob = SkillsConfigManager(str(isolate_home)).load_global() - assert 'only-here' not in glob.get('disabled', []) + 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( @@ -318,6 +434,25 @@ async def test_disable_skill_named_project_is_not_a_scope( 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): @@ -375,6 +510,41 @@ async def test_enable_without_scope_asks_when_both_copies_exist( 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): diff --git a/tests/command/test_search_cmds.py b/tests/command/test_search_cmds.py index 04ba86b4c..f30408924 100644 --- a/tests/command/test_search_cmds.py +++ b/tests/command/test_search_cmds.py @@ -57,6 +57,8 @@ 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' diff --git a/tests/e2e/test_tui_webui_usability.py b/tests/e2e/test_tui_webui_usability.py index ebc4de380..d657811dd 100644 --- a/tests/e2e/test_tui_webui_usability.py +++ b/tests/e2e/test_tui_webui_usability.py @@ -100,9 +100,9 @@ def test_skills_list_without_runtime_shows_imported_ids( class TestDefaultScopeFootgun: """``/mcp add`` omits to this folder (project) and says so. - ``global`` is every folder on this machine. ``/skills add`` and - ``/skills enable`` share one default: project when a work dir is open, - otherwise global. + ``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 @@ -137,9 +137,9 @@ def test_mcp_add_without_scope_warns_about_settings_page( assert 'webui' not in lowered @pytest.mark.usability - def test_skills_add_and_enable_share_project_default_when_work_dir( + def test_skills_add_lands_on_this_machine_and_disable_follows_it( self, isolated_home, work_dir): - """Same command family, one default: this folder if TUI has --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' @@ -147,11 +147,11 @@ def test_skills_add_and_enable_share_project_default_when_work_dir( (src / 'SKILL.md').write_text('# scoped\n', encoding='utf-8') added = run_slash(f'/skills add {src}', rt) assert 'Imported' in added.content - assert '(project)' in added.content - assert (work_dir / '.ms_agent' / 'skills' / 'scoped-skill').is_dir() - assert not (isolated_home / 'skills' / 'scoped-skill').exists() + 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 (project)' in disabled.content + assert 'disable scoped-skill (global)' in disabled.content class TestModelSwitchShadowsWebuiDefault: diff --git a/tests/e2e/test_update_config_boot.py b/tests/e2e/test_update_config_boot.py deleted file mode 100644 index 4e09b9b25..000000000 --- a/tests/e2e/test_update_config_boot.py +++ /dev/null @@ -1,227 +0,0 @@ -"""86119007: update-config skill loads on the real TUI / WebUI boot path. - -Same order as run_loop: prepare_runtime → prepare_tools → prepare_skills. -""" -from __future__ import annotations - -import asyncio -import json -import os -from pathlib import Path - -import pytest -from omegaconf import OmegaConf - -from ms_agent.agent.llm_agent import LLMAgent -from ms_agent.command.skill_bridge import expand_skill -from ms_agent.llm.utils import Message, collect_response -from ms_agent.skill.catalog import BUILTIN_SKILLS_DIR -from ms_agent.tui.app import TuiApp -from ms_agent.tui.managed_config import merge_skills_into_config - - -def _boot_agent(cfg) -> LLMAgent: - agent = LLMAgent(config=cfg, tag='e2e-update-config') - agent.prepare_runtime() - - async def _setup(): - await agent.prepare_tools() - await agent.prepare_skills() - - asyncio.run(_setup()) - return agent - - -def _cleanup(agent: LLMAgent) -> None: - asyncio.run(agent.cleanup_tools()) - - -def _tui_agent(work: Path, home: Path) -> LLMAgent: - cfg = TuiApp._load_runtime_config( - 'unused.yaml', str(work), explicit_config=False) - cfg = TuiApp._prepare_config(cfg, None, str(work)) - cfg = merge_skills_into_config(cfg, str(home), str(work)) - return _boot_agent(cfg) - - -def test_builtin_skill_is_on_disk(): - assert (BUILTIN_SKILLS_DIR / 'update-config' / 'SKILL.md').is_file(), ( - f'bundled skill missing at {BUILTIN_SKILLS_DIR}') - - -def test_tui_boot_loads_update_config_into_prompt_and_skill_view( - isolated_home, work_dir): - """Default TUI session: L1 in system prompt, playbook only on skill_view.""" - agent = _tui_agent(work_dir, isolated_home) - try: - assert agent._skill_catalog is not None - skill = agent._skill_catalog.get_skill('update-config') - assert skill is not None - - msgs = asyncio.run(agent.create_messages('帮我把长期记忆打开')) - system = msgs[0].content - assert 'update-config' in system - assert 'skill_view' in system - assert 'streamable_http' not in system - assert 'memory.unified_memory' not in system - - toolset = next( - t for t in (agent.tool_manager.extra_tools or []) - if getattr(t, 'TOOL_SERVER_NAME', None) == 'skills') - viewed = json.loads( - toolset._handle_skill_view({'skill_id': 'update-config'})) - memory_md = str( - (work_dir.resolve() / '.ms_agent' / 'memory' / 'MEMORY.md')) - project_mcp = str(work_dir.resolve() / '.ms_agent' / 'mcp.json') - global_mcp = str(isolated_home / 'mcp.json') - body = viewed['content'] - assert memory_md in body - assert project_mcp in body - assert global_mcp in body - assert '/memory on' in body - assert '/mcp add' in body - assert 'memory.unified_memory' in body - assert '{memory_md}' not in body - finally: - _cleanup(agent) - - -def test_tui_slash_update_config_expands_playbook(isolated_home, work_dir): - agent = _tui_agent(work_dir, isolated_home) - try: - result = expand_skill( - agent._skill_catalog, 'update-config', '打开 memory') - assert result is not None - assert '/memory on' in result.content - assert '打开 memory' in result.content - assert str(isolated_home / 'mcp.json') in result.content - finally: - _cleanup(agent) - - -def test_webui_defaults_also_load_update_config(isolated_home, work_dir): - """WebUI _apply_webui_defaults already opts into skills; builtin must load.""" - from ms_agent.config.resolver import ConfigResolver - - resolver = ConfigResolver( - global_dir=str(isolated_home), project_root=str(work_dir)) - cfg = resolver.resolve(agent_config=None, project_path=str(work_dir)) - if OmegaConf.select(cfg, 'skills', default=None) is None: - OmegaConf.update(cfg, 'skills', {}, merge=True) - OmegaConf.update(cfg, 'skills.prompt_injection', 'all', merge=True) - OmegaConf.update(cfg, 'output_dir', str(work_dir), merge=True) - cfg = merge_skills_into_config(cfg, str(isolated_home), str(work_dir)) - - agent = _boot_agent(cfg) - try: - assert agent._skill_catalog.get_skill('update-config') is not None - system = asyncio.run( - agent.create_messages('add an MCP server'))[0].content - assert 'update-config' in system - assert 'skill_view' in system - finally: - _cleanup(agent) - - -def _tool_name(call) -> str: - if isinstance(call, dict): - return str(call.get('tool_name') or '') - return str(getattr(call, 'tool_name', '') or '') - - -def _tool_args(call) -> dict: - raw = call.get('arguments') if isinstance(call, dict) else getattr( - call, 'arguments', {}) - if isinstance(raw, str): - try: - raw = json.loads(raw) - except json.JSONDecodeError: - return {} - return raw if isinstance(raw, dict) else {} - - -@pytest.mark.live -def test_live_model_views_update_config_to_enable_memory( - isolated_home, work_dir): - """Real DashScope turn: NL 'open memory' must load the skill, not guess config.yaml.""" - 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)) - cfg = merge_skills_into_config(cfg, str(isolated_home), str(work_dir)) - OmegaConf.update(cfg, 'generation_config.stream', False, merge=True) - OmegaConf.update(cfg, 'interactive', False, merge=True) - - agent = _boot_agent(cfg) - try: - agent.prepare_llm() - agent.runtime.llm = agent.llm - query = ( - '帮我把这个项目的长期记忆打开,以后对话都要记住:代码用 ruff。' - '按 ms-agent 真正的配置方式做,不要改 config.yaml。' - '告诉我 MEMORY.md 的绝对路径。') - messages = asyncio.run(agent.create_messages(query)) - tools = asyncio.run(agent.tool_manager.get_tools()) - - viewed = False - viewed_body = '' - calls_log = [] - final_text = '' - for _round in range(4): - reply = collect_response( - agent.llm.generate(messages, tools=tools)) - assert reply is not None - agent.handle_new_response(messages, reply) - names = [_tool_name(c) for c in (reply.tool_calls or [])] - calls_log.append(names) - if not reply.tool_calls: - final_text = reply.content or '' - break - for call in reply.tool_calls: - args = _tool_args(call) - if ('skill_view' in _tool_name(call) - and args.get('skill_id') == 'update-config'): - viewed = True - before = len(messages) - asyncio.run(agent.parallel_tool_call(messages)) - if viewed and not viewed_body: - for msg in messages[before:]: - if getattr(msg, 'role', '') == 'tool': - viewed_body += msg.content or '' - - print('LIVE_TOOL_ROUNDS', calls_log) - print('LIVE_VIEWED', viewed) - print('LIVE_FINAL_HEAD', (final_text or '')[:500]) - assert viewed, ( - f'model never called skill_view(update-config); calls={calls_log} ' - f'final={final_text[:300]!r}') - memory_md = str( - (work_dir.resolve() / '.ms_agent' / 'memory' / 'MEMORY.md')) - assert memory_md in viewed_body - blob = (final_text + viewed_body).lower() - assert '/memory on' in blob or 'memory on' in blob - assert 'unified_memory' not in (final_text or '').lower() or ( - '不要' in (final_text or '') or 'do not' in (final_text or '').lower() - or 'not' in (final_text or '').lower()) - if final_text: - assert memory_md in final_text or '.ms_agent/memory/MEMORY.md' in final_text - finally: - _cleanup(agent) diff --git a/tests/llm/test_model_discovery.py b/tests/llm/test_model_discovery.py index f93b0ea26..29e91925b 100644 --- a/tests/llm/test_model_discovery.py +++ b/tests/llm/test_model_discovery.py @@ -119,6 +119,21 @@ def test_fetch_model_ids_ok(): 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) diff --git a/tests/mcp/test_mcp_runtime.py b/tests/mcp/test_mcp_runtime.py index 27911909f..ac17904be 100644 --- a/tests/mcp/test_mcp_runtime.py +++ b/tests/mcp/test_mcp_runtime.py @@ -73,6 +73,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 +439,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 +696,46 @@ class _Config: ) tm._skip_mcp_reindex = True return tm + + +@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 index 5a0b51b17..45cae8fed 100644 --- a/tests/memory/test_file_storage_notice.py +++ b/tests/memory/test_file_storage_notice.py @@ -1,9 +1,18 @@ """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.user_notice import CHAR_LIMIT_NOTICE +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 @@ -24,3 +33,48 @@ async def test_char_limit_is_the_tool_result_and_does_not_stick(tmp_path): }) 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/prompting/test_workspace_internals_hint.py b/tests/prompting/test_workspace_internals_hint.py index 2893856ed..2f50e75ff 100644 --- a/tests/prompting/test_workspace_internals_hint.py +++ b/tests/prompting/test_workspace_internals_hint.py @@ -111,8 +111,7 @@ def test_it_reaches_the_system_prompt(tmp_path): def test_system_prompt_does_not_dump_config_playbook(tmp_path, monkeypatch): - """86119007: architecture belongs in the update-config skill, not the - always-on system prompt (Claude Code SkillTool pattern).""" + """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)) 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 index b58978fa9..33b5137f5 100644 --- a/tests/skills/test_update_config.py +++ b/tests/skills/test_update_config.py @@ -1,111 +1,34 @@ -"""Bundled update-config skill — Claude Code progressive-disclosure analog. +"""The bundled update-config skill is not loaded. -L1: catalog listing (name + description) in the skill section. -L2: skill_view / /update-config expands the playbook with live paths. -The always-on system prompt must not contain the playbook. +TUI still prepares the skill catalog so user and project skills work. """ from __future__ import annotations -import json +import asyncio +from unittest.mock import AsyncMock, MagicMock from omegaconf import OmegaConf -from ms_agent.command.skill_bridge import expand_skill +from ms_agent.agent.llm_agent import LLMAgent from ms_agent.skill.catalog import BUILTIN_SKILLS_DIR, SkillCatalog -from ms_agent.skill.prompt_injector import SkillPromptInjector -from ms_agent.skill.skill_tools import SkillToolSet +from ms_agent.skill.harness import fill_harness_placeholders +from ms_agent.tui.app import TuiApp -def _catalog(): +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({})) - return catalog + assert catalog.get_skill('update-config') is None -def test_builtin_dir_contains_update_config(): - skill_md = BUILTIN_SKILLS_DIR / 'update-config' / 'SKILL.md' - assert skill_md.is_file() - - -def test_catalog_lists_update_config_in_l1_summary(): - catalog = _catalog() - skill = catalog.get_skill('update-config') - assert skill is not None - summary = catalog.get_skills_summary() - assert 'update-config' in summary - assert 'MEMORY.md' in skill.description or '/memory' in skill.description - assert 'mcp.json' in skill.description or '/mcp' in skill.description - # L1 is discovery-only: live paths stay out of the listing. - assert '{memory_md}' not in summary - assert '.ms_agent/memory/MEMORY.md' not in summary - - -def test_l1_prompt_section_does_not_include_playbook(): - injector = SkillPromptInjector(_catalog()) - section = injector.build_skill_prompt_section() - assert 'Available Skills' in section - assert 'update-config' in section - assert 'skill_view' in section - # Playbook body stays out of L1 (description may mention /memory as a trigger). - assert '## Two scopes' not in section - assert 'memory.unified_memory' not in section - assert 'streamable_http' not in section - - -def test_skill_view_fills_live_paths(tmp_path, monkeypatch): - home = tmp_path / 'ms_home' - home.mkdir() - monkeypatch.setenv('MS_AGENT_HOME', str(home)) - work = tmp_path / 'proj' - work.mkdir() - cfg = OmegaConf.create({'output_dir': str(work)}) - catalog = _catalog() - toolset = SkillToolSet(cfg, catalog, enable_manage=False) - data = json.loads(toolset._handle_skill_view({'skill_id': 'update-config'})) - - memory_md = str((work / '.ms_agent' / 'memory' / 'MEMORY.md').resolve()) - project_mcp = str(work / '.ms_agent' / 'mcp.json') - global_mcp = str(home / 'mcp.json') - assert memory_md in data['content'] - assert project_mcp in data['content'] - assert global_mcp in data['content'] - assert '{memory_md}' not in data['content'] - assert '{home}' not in data['content'] - assert '/memory on' in data['content'] - assert '/mcp add' in data['content'] - assert 'config.yaml' in data['content'] - assert 'memory.unified_memory' in data['content'] - assert 'Do not' in data['content'] - - -def test_slash_expand_fills_live_paths(tmp_path, monkeypatch): - home = tmp_path / 'ms_home' - home.mkdir() - monkeypatch.setenv('MS_AGENT_HOME', str(home)) - catalog = _catalog() - result = expand_skill(catalog, 'update-config', 'turn memory on') - assert result is not None - assert '/memory on' in result.content - assert str(home / 'mcp.json') in result.content - assert 'turn memory on' in result.content - assert '{memory_md}' not in result.content - - -def test_other_skills_are_not_rewritten(tmp_path): - from ms_agent.skill.harness import fill_harness_placeholders +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_enable_prepare_skills(tmp_path, monkeypatch): - """Default TUI (no --config) must load bundled skills.""" - import asyncio - from unittest.mock import AsyncMock, MagicMock - - from ms_agent.agent.llm_agent import LLMAgent - from ms_agent.tui.app import TuiApp - +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' @@ -121,9 +44,5 @@ def test_tui_defaults_enable_prepare_skills(tmp_path, monkeypatch): 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 not None - content = agent._build_system_content() - assert 'update-config' in content - assert 'skill_view' in content - assert 'streamable_http' not in content - assert 'memory.unified_memory' not in content + assert agent._skill_catalog.get_skill('update-config') is None + assert 'update-config' not in agent._build_system_content() diff --git a/tests/tui/test_live_config.py b/tests/tui/test_live_config.py index fa1c51669..bc16b7c12 100644 --- a/tests/tui/test_live_config.py +++ b/tests/tui/test_live_config.py @@ -68,3 +68,124 @@ async def test_unchanged_token_does_not_rebuild(): with patch('ms_agent.llm.LLM.from_config') as rebuild: await apply_live_config(agent, None) rebuild.assert_not_called() + + +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_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_tui_config_align.py b/tests/tui/test_tui_config_align.py index c1e495771..29781b1d4 100644 --- a/tests/tui/test_tui_config_align.py +++ b/tests/tui/test_tui_config_align.py @@ -379,7 +379,15 @@ async def fake_run(query=None, stream=True): "'The product is not activated'}}") raise EOFError() - app.agent = SimpleNamespace(run=fake_run) + app.agent = SimpleNamespace( + run=fake_run, + config=OmegaConf.create({ + 'llm': { + 'service': 'openai', + 'model': 'm', + }, + }), + ) await app._serve() out = buf.getvalue() @@ -423,10 +431,113 @@ async def fake_run(query=None, stream=True): 'Attempted to exit cancel scope in a different task') raise EOFError() - app.agent = SimpleNamespace(run=fake_run) + 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 From 2fe840168bbf34a893225a474f1979d158b2e6ae Mon Sep 17 00:00:00 2001 From: suluyan Date: Wed, 14 Oct 2026 17:29:46 +0800 Subject: [PATCH 07/21] docs: remove the TUI and WebUI alignment checklist The checklist is no longer part of this branch. Co-authored-by: Cursor --- docs/tui-webui-align-e2e.md | 403 ------------------------------------ 1 file changed, 403 deletions(-) delete mode 100644 docs/tui-webui-align-e2e.md diff --git a/docs/tui-webui-align-e2e.md b/docs/tui-webui-align-e2e.md deleted file mode 100644 index 38dc57fba..000000000 --- a/docs/tui-webui-align-e2e.md +++ /dev/null @@ -1,403 +0,0 @@ -# TUI / WebUI 设置账本 — 端到端验收 - -两端必须同一 `MS_AGENT_HOME`、同一工作目录。**不要用开发者自己的 `~/.ms_agent`。** - -代码: - -- SDK:`modelscope-agent-feat-tui-align`(分支 `feat/tui-align-webui`) -- WebUI:GitLab `ms-agent-webui-feat-tui-align`(WebUI 后端请用这份 SDK,例如 `PYTHONPATH` 或 editable install) - -## 共用环境 - -```bash -export MS_AGENT_HOME=/tmp/ms-agent-align-e2e -rm -rf "$MS_AGENT_HOME" -mkdir -p "$MS_AGENT_HOME" /tmp/align-work - -# WebUI:启动进程时带上同一 MS_AGENT_HOME -# TUI(在 SDK 工作树): -ms-agent tui --work-dir /tmp/align-work -``` - -换期前可清空 `$MS_AGENT_HOME`。磁盘是唯一验收源。账本改完后,同一会话的下一条用户消息才生效,不必 `/new`。 - -对齐标准:**同一账本**(TUI slash 能管 WebUI 设置页写的那些文件),不是 TUI/WebUI 界面完全一致。 - -**不测:** 工作区编辑器、多项目 IDE、卡片回放、外观主题、权限 UX、把 TUI 事件还原成 WebUI 卡片。 - ---- - -## 本期功能清单(提测用) - -每条都要两边看:TUI 命令 → 磁盘 → 刷新 WebUI;以及 WebUI 改完 → TUI 再读。建议按 0 → 5 顺序,0 不过后面的设置对不齐。 - -### A. 项目与会话(第 0 期) - -| # | 功能 | 怎么测 | 通过标准 | -|---|---|---|---| -| A1 | 同一文件夹 = 同一项目 | WebUI 打开 `W`,再 TUI `--work-dir W` | 不是两套 project id | -| A2 | WebUI 会话 TUI 能续 | WebUI 说一句 → TUI `/sessions` `/resume` | 能读到那句 | -| A3 | TUI 会话 WebUI 能看到 | TUI 说一句 → 刷新 WebUI | 同一项目、同一会话树 | -| A4 | TUI 空 home 能启动 | 不先开 WebUI,直接 `ms-agent tui` | 不因 todo_list 当 MCP 崩溃 | -| A5 | TUI 默认模型跟 WebUI | WebUI 设默认模型后新开 TUI(无 `--config`) | 第一轮就用该模型,不必先 `/model` | -| A6 | 显式 `--config` 不被 settings 改掉 | `ms-agent tui --config custom.yaml` | 仍用 yaml 里的模型 | - -### B. 模型(第 0 / 3 期) - -落盘:`$MS_AGENT_HOME/settings.json` 的 `providers` / `default_model` / `llm`。 - -| # | 功能 | TUI | 通过标准 | -|---|---|---|---| -| B1 | 列出供应商 | `/model` `/model list` | 与 WebUI 模型设置同源;key 只显示 set/missing,无明文 | -| B2 | 切换当前模型 | `/model ` 或 `/model /` | 状态栏模型变了;settings 的 default_model 更新(之后的新对话用它);当前 session.json 的 model 也更新(回到这条对话仍是它);下一轮走新模型。不写项目 config.yaml | -| B3 | 新增供应商 | `/model provider add key= url= protocol=` | WebUI 模型页出现该项 | -| B4 | 改 key | `/model provider key \|clear` | json 变了;列表仍不打印明文;当前供应商会尽量立刻生效 | -| B5 | 改 base_url | `/model provider url \|clear` | 同上 | -| B6 | 改协议/名称等 | `/model provider set protocol= name=` | 只改给的字段,不把别的冲掉 | -| B7 | 模型目录 | `/model catalog add\|remove ` | WebUI 该供应商的模型列表同步 | -| B8 | 不能删内置 | `/model provider remove openai`(无覆盖时) | 拒绝;有覆盖时只清覆盖,内置还在 | - -### C. 搜索(第 1 期) - -落盘:`settings.json` → `tools.web_search`。 - -| # | 功能 | TUI | 通过标准 | -|---|---|---|---| -| C1 | 看当前引擎 | `/search` `/search list` | 未配置时默认 tavily;当前项前有 `*` | -| C2 | 切引擎 | `/search engine tavily\|exa\|serpapi\|arxiv` | WebUI 搜索页下拉一致 | -| C3 | 写/清 key | `/search key ` `/search key clear` | 按**当前引擎**写 `{engine}_api_key`;arxiv 无需 key | -| C4 | 切引擎不丢别人的 key | 先给 exa 写 key,再切 tavily | json 里 `exa_api_key` 还在 | -| C5 | 开关 | `/search enable\|disable` | WebUI 搜索启用状态一致 | -| C6 | WebUI → TUI | WebUI 改引擎并保存 | TUI `/search` 已是新引擎 | -| C7 | (有网,非必须)对话走该引擎 | `/new` 后让模型去搜 | 工具用的是所选引擎 | - -### D. 指令与 Profile(第 2 期) - -| # | 功能 | TUI | 落盘 | 通过标准 | -|---|---|---|---|---| -| D1 | 看指令 | `/instruction` 或 `/ins` | — | 能分开显示全局 / 项目 | -| D2 | 写/清全局指令 | `/instruction global \|clear` | `$MS_AGENT_HOME/AGENTS.md` 用户区 | 保留模板头;WebUI 个性化指令同一句 | -| D3 | 写/清项目指令 | `/instruction project \|clear` | `/.ms_agent/AGENTS.md` | **绝不改** 仓库根 `/AGENTS.md` | -| D4 | 看 Profile | `/profile` | `$MS_AGENT_HOME/PROFILE.md` | Call me / About | -| D5 | 称呼 | `/profile callme \|clear` | 同上 | 清称呼不抹掉 About | -| D6 | 自我介绍 | `/profile about \|clear` | 同上 | 清 About 不抹掉称呼 | -| D7 | 下一轮生效 | 改完再发一轮 | — | 不必 `/new`;本轮已发出的 prompt 不回写 | - -### E. MCP(第 0 / 4 期) - -落盘:`$MS_AGENT_HOME/mcp.json`(及 `settings.json` 的 mcp_servers);项目级 `/.ms_agent/mcp.json`。 - -| # | 功能 | TUI | 通过标准 | -|---|---|---|---| -| E1 | 列表 | `/mcp`(当前列表 + usage);`/mcp list [global\|project]` | 与 WebUI MCP 页一致 | -| E2 | 添加 HTTP | `/mcp add [global\|project] url=` | WebUI 能看到同名同 URL | -| E3 | 添加 stdio | `/mcp add global command="npx -y …"` | command/args 拆对 | -| E4 | 更新 | `/mcp update [global\|project] url=\|command=` | 同一条被改,不是又建一条 | -| E5 | 启用/停用 | `/mcp enable\|disable [global\|project]` | WebUI 开关一致 | -| E6 | 删除 | `/mcp remove [global\|project]` | WebUI 不再显示(或项目级遮罩全局) | -| E7 | 导入 json | `/mcp json ` | 能进 mcp.json | -| E8 | 本会话连接 | add/update/enable/disable/remove 之后,再发一条用户消息 | 只改了的服务器会连上或断开;没改的保持连接。命令只回「下一条消息生效。」 | - -### F. 技能(第 0 / 4 期) - -托管副本:`$MS_AGENT_HOME/skills/` 或 `/.ms_agent/skills/`。 - -| # | 功能 | TUI | 通过标准 | -|---|---|---|---| -| F1 | 列表 | `/skills`(当前列表 + usage);`/skills list` | 与 WebUI 技能页能对上 | -| F2 | 导入 | `/skills add <含 SKILL.md 的目录> [global\|project]` | 拷进 live tree;WebUI 能看到;源目录还在 | -| F3 | 启用/停用 | `/skills enable\|disable [global\|project]` | 写 skills.json disabled;本会话能跟上 | -| F4 | 删除托管副本 | `/skills remove [global\|project]` | 只删 live tree;**源目录不删**;WebUI 那条消失 | -| F5 | 不删自动发现 | 对 `.agents/skills` 等只读项 `/skills remove` | 提示 disable instead,不得 rmtree 用户仓库 | - -### G. 记忆(第 5 期) - -两套开关,不要测混: - -1. **全局默认** `settings.json` → `personalization.memory_enabled` / `memory_backend`:只影响**尚未登记的新文件夹**。 -2. **当前项目** `projects//.ms_agent/project.json` → `memory_enabled`:真正注入记忆工具。file 后端文件在 `/.ms_agent/memory/MEMORY.md`。 - -| # | 功能 | TUI | 通过标准 | -|---|---|---|---| -| G1 | 看状态 | `/memory` | 能区分全局默认 vs 本项目 | -| G2 | 全局默认 | `/memory global on\|off` | WebUI 个性化默认记忆一致;**已有项目不变** | -| G3 | 新文件夹继承 | 全局 on 后,TUI 打开一个从没登记过的目录 | 该项目 memory_enabled 为 true | -| G4 | 本项目开/关 | `/memory on\|off` 或 `/memory project on\|off` | WebUI 该项目记忆开关一致;file 时对话能走到 unified_memory | -| G5 | 后端 | `/memory backend file\|vector`(全局默认);`/memory project backend file\|vector`(本项目) | file:TUI 用 MEMORY.md;vector:只落盘给 WebUI,**TUI 不得悄悄写成 file** | -| G6 | WebUI → TUI | WebUI 打开同一项目的 file 记忆 | 新开 TUI `/memory` 项目为 on | -| G7 | 中途关掉 | 已经 load 过记忆后再 `/memory off`,再发一条用户消息 | 记忆工具卸掉;不必 `/new` | - -### 本会话未覆盖(不要当成回归失败) - -- 工作区编辑器、多项目、卡片回放、外观 -- 权限卡片 / Ask 流 UX -- TUI 把 vector/mem0 跑起来(vector 只要求落盘,真正跑在 WebUI) - -逐步操作见下文各期;磁盘对不上时以 json / markdown 文件为准。 - ---- - -## 第 0 期:同一文件夹互续(请测试先跑) - -验收:TUI 和 WebUI 看到同一项目、同一套 session,不再拆成两套 id。 - -### 0.1 WebUI → TUI 续聊 - -1. 按上面清空并 export `MS_AGENT_HOME`,工作目录 `W=/tmp/align-work`。 -2. 打开 GitLab WebUI,用「打开文件夹 / 使用已有目录」选 `W`(不要「从零新建」到别的路径)。 -3. 新开对话,发一句:`hello from web`。记下侧栏会话名称或 id。 -4. 同一终端环境启动 TUI:`ms-agent tui --work-dir /tmp/align-work`。 -5. 输入 `/sessions`:应能看到刚才那条 WebUI 会话。 -6. `/resume `:应能读到 `hello from web`。 -7. 再发一句 `hello from tui`,然后 `/quit`。 -8. 刷新 WebUI 同一项目:侧栏仍是同一项目;点进刚才的会话(或 TUI 新开的那条)能看到 `hello from tui`。 - -**失败:** 出现两套 project、WebUI 看不到 TUI 说的话、TUI `/sessions` 为空、或 resume 没有 WebUI 那句。 - -### 0.2 TUI → WebUI 发现项目 - -1. 可用同一 `W`,或清空 home 后只先开 TUI:`ms-agent tui --work-dir /tmp/align-work`。 -2. TUI 发一句 `hello from tui first`,`/quit`。 -3. 打开 WebUI(同一 `MS_AGENT_HOME`):项目列表应出现该文件夹;点进去能看到那条会话。 - -**失败:** WebUI 项目列表没有这个目录,或打开后会话是空的。 - -### 0.3 已有 slash 写盘(冒烟) - -1. TUI `/model list`:能列出供应商(与 WebUI 设置 → 模型同源 `settings.json`)。 -2. TUI:`/mcp add docs global url=https://example.invalid/mcp` -3. 刷新 WebUI 设置 → MCP:应出现名为 `docs` 的项,URL 一致。 -4. (可选)TUI `/skills` 能列出;有现成 `SKILL.md` 目录时 `/skills add global`,WebUI 技能页能看到。 - -**失败:** WebUI 完全看不到 TUI 刚加的 MCP。 - -### 0.4 空 home + 默认 yaml:TUI 能启动(todo_list 不是 MCP) - -先前:`_apply_session` 写入 `tools.todo_list.plan_filename` 却没有 `mcp: false`,ToolManager 把它当 MCP 去连,报 `'url' or 'command' parameter is required`。WebUI 因为 `settings.json` 里已有 `todo_list.mcp: false` 所以没事。 - -1. 清空 `$MS_AGENT_HOME`(不要先开 WebUI,不要手改 yaml)。 -2. `ms-agent tui --work-dir /tmp/align-work` -3. 应出现会话 banner 和输入框,**不得**在启动时因 todo_list / MCP url 崩溃。 -4. 空 home、还没有 API key:应提示 `/model provider key` / `/model openai/`,**不得直接退出**。配好 key 后再发一句即可;`/quit` 仍能离开。 -5. 已有 key 时输入 `/quit` 正常退出即可。 - -**失败:** 启动即 traceback,或日志里出现 `'url' or 'command' parameter is required`。 - -### 0.5 TUI 第一轮推理用 WebUI 默认模型(不必再 `/model`) - -先前:`/model list` 读 `settings.json`,真正跑模型仍走 `Config.from_task(agent.yaml)`(包装里的 Qwen3-235B)。要对齐得手动 `/model openai/qwen3.7-plus`。 - -1. 同一 `MS_AGENT_HOME`。WebUI 设置 → 模型,默认选 `openai/qwen3.7-plus`(或当前环境真实在用的那条)并保存。 -2. 新开 TUI:`ms-agent tui --work-dir /tmp/align-work`。`/model` 写两端共用的 `default_model`,并记下当前对话自己的模型(`session.json` 的 `model` / `model_provider`),不再给这个文件夹钉一份 `.ms_agent/config.yaml`。之后在 WebUI 改默认,下一次新建的对话跟上;用 `/resume` 回到改之前的对话,模型仍是那条对话自己的。 -3. `/model`(无参数)或看 banner:当前模型应是 WebUI 刚设的那条,而不是 yaml 里的 `Qwen/Qwen3-235B-A22B-Instruct-2507`。 -4. 发一句短回复(如 `ping`)。请求应打到该默认模型,不必先 `/model openai/qwen3.7-plus`。 -5. (对照)`ms-agent tui --config /path/to/custom.yaml --work-dir ...`:应继续用 yaml 里写死的模型,不被 settings 改掉。 - -**失败:** 默认 TUI 仍在用包装 yaml 的模型;或显式 `--config` 反而被 settings 覆盖;或 TUI `/model` 之后 WebUI 再改默认,这个文件夹的 TUI 仍钉在旧模型上。 - ---- - -## 第 1 期:搜索 - -磁盘:`$MS_AGENT_HOME/settings.json` → `tools.web_search.engine` 以及 `{engine}_api_key`(`arxiv` 无 key)。切引擎不得删掉别的引擎的 key。 - -TUI 命令: - -``` -/search -/search list -/search engine tavily|exa|serpapi|arxiv -/search key -/search key clear -/search enable|disable -``` - -### 1.1 TUI → WebUI - -1. 同一隔离 home。TUI:`/search` 默认引擎应为 `tavily`(未配置时)。 -2. `/search engine arxiv`。打开 `settings.json`,确认 `tools.web_search.engine` 为 `arxiv`。 -3. 刷新 WebUI 设置 → 搜索:下拉为 arXiv,无 API key 框(或标明无需 key)。 -4. TUI:`/search engine exa` 然后 `/search key sk-test-exa`。 -5. json 中应有 `exa_api_key`;刷新 WebUI:引擎为 Exa,且显示已配置 key(不要要求页面把明文 key 打出来)。 -6. TUI 再 `/search engine tavily`:json 里 `exa_api_key` **仍在**,`engine` 为 `tavily`。WebUI 切到 Tavily 后,再切回 Exa,key 仍显示已配置。 - -### 1.2 WebUI → TUI - -1. WebUI 搜索页改成 `serpapi`(或当前页上另一个引擎)并保存。 -2. TUI `/search`:Engine 应已变成该引擎。`/search list` 当前项前有 `*`。 - -### 1.3 对话是否走到该引擎(有网,非必须) - -1. `/new` 或新开会话后,请模型「用搜索查一下今天日期」之类。 -2. 工具调用应使用所选引擎。当前会话若仍用旧引擎,先 `/new` 再试。 - -**失败:** 两边引擎不一致;切 Tavily 后 Exa 的 key 从 json 里消失;arxiv 仍出现 key 输入且 TUI `/search key` 能写进去。 - ---- - -## 第 2 期:指令与 Profile(`AGENTS.md` / `PROFILE.md`) - -磁盘(与 WebUI「个性化 / 项目指令 / 用户 Profile」同一套文件): - -| 范围 | 文件 | TUI | -|---|---|---| -| 全局指令 | `$MS_AGENT_HOME/AGENTS.md` 的用户区(保留模板头) | `/instruction global …` | -| 项目指令 | `/.ms_agent/AGENTS.md` | `/instruction project …` | -| 用户 Profile | `$MS_AGENT_HOME/PROFILE.md` | `/profile callme` / `/profile about` | - -**界面绝不写** `/AGENTS.md`(仓库根,给团队/编码助手用的;SDK 只读,排在私有槽之前)。 - -``` -/instruction -/instruction global|project -/instruction global|project -/instruction global|project clear -/profile -/profile callme |clear -/profile about |clear -``` - -下一轮对话就会读到新文件;不必 `/new`,但已发出的那一轮不会回写旧 system prompt。 - -### 2.1 TUI → WebUI 全局指令 - -1. 同一隔离 home。TUI:`/instruction global Always answer in French.` -2. 打开 `$MS_AGENT_HOME/AGENTS.md`:应仍有 `---` 头,用户区有那句。 -3. 刷新 WebUI 设置 → 个性化 → 个性化指令:应显示同一句。 -4. 新开一轮对话,模型应遵守该全局指令。 - -### 2.2 TUI → WebUI 项目指令(只写私有槽) - -1. 工作目录 `W` 里若已有仓库根 `AGENTS.md`,先记下内容(或故意写一句 `keep me at root`)。 -2. TUI:`/instruction project This project uses FastAPI.` -3. 确认 `W/.ms_agent/AGENTS.md` 是项目指令;**根目录 `W/AGENTS.md` 一字未改**。 -4. 刷新 WebUI 该项目的「项目指令」:应是 FastAPI 那句,不是根文件。 - -### 2.3 WebUI → TUI - -1. WebUI 个性化指令改成 `Be terse.` 并保存。 -2. TUI `/instruction global`:应看到 `Be terse.` -3. WebUI 用户 Profile:称呼 `Alice`,自我介绍 `I work on agents.` 保存。 -4. TUI `/profile`:Call me 为 Alice,About 含那句。 - -### 2.4 Profile 字段互不覆盖 - -1. TUI `/profile callme Alice` 再 `/profile about researcher` -2. `/profile callme clear`:称呼空了,About 仍是 researcher。 -3. 打开 `PROFILE.md`:`- Call me:` 行已去掉,自我介绍还在。 - -**失败:** TUI 改了仓库根 `AGENTS.md`;WebUI 看不到 TUI 刚写的全局/项目指令;`/profile callme` 把 about 区抹掉。 - ---- - -## 第 3 期:模型供应商 CRUD(key / base_url / catalog) - -磁盘:`$MS_AGENT_HOME/settings.json` 的 `providers` / `default_model` / `llm`(与 WebUI「模型设置」同一套)。内置供应商不能删;同名自定义条目是凭证覆盖。列表里的 key 只显示 set/missing,不打印明文。`/model list live` 对已配 key 的供应商现拉 `/v1/models`,只展示对话/视觉理解 id(图生、视频生、embedding 会丢掉),结果不写回 json。 - -``` -/model list -/model list live [provider] -/model / -/model provider add [key=] [url=] [protocol=openai|anthropic] [name=] -/model provider set [key=] [url=] [protocol=] [name=] -/model provider key |clear -/model provider url |clear -/model provider remove -/model catalog add -/model catalog remove -``` - -当前正在用的供应商改 key/url 后,TUI 会尽量立刻重建 LLM。 - -### 3.1 TUI → WebUI - -1. 同一隔离 home。TUI:`/model provider add acme key=sk-test-acme url=https://example.invalid/v1 protocol=openai` -2. `/model catalog add acme a-1` -3. 打开 `settings.json`:应有 `providers.acme`,含 key、url、`models: ["a-1"]`。 -4. 刷新 WebUI 设置 → 模型:应出现 acme,且显示已配置 key(不要要求页面打出明文)。 -5. TUI:`/model provider key acme sk-test-acme-2`。json 里 key 已换;WebUI 刷新仍显示已配置。 -6. TUI:`/model openai/qwen3.7-plus`(或当前真实在用的那条)后发一句 `ping`,应能出回复。 - -### 3.2 不能删内置 - -1. TUI:`/model provider remove openai`(若没有自定义覆盖):应拒绝。 -2. 若曾 `/model provider key openai …` 写过覆盖:`remove` 只清覆盖,内置 openai 仍在 `/model list`。 - -**失败:** WebUI 看不到 TUI 刚加的供应商;`/model list` 打印了明文 key;删掉了内置 openai。 - ---- - -## 第 4 期:`/mcp update` 与 `/skills remove` - -磁盘:MCP 仍是 `mcp.json` / `settings.json` 的 `mcpServers`;技能删除只动 **managed live tree**(`$MS_AGENT_HOME/skills/` 或 `/.ms_agent/skills/`),与 WebUI 删「托管副本」一致。不要删 `.agents/skills` 里自动发现的目录。 - -``` -/mcp update [global|project] url=... | command=... -/skills remove [global|project] -``` - -### 4.1 MCP update - -1. TUI:`/mcp add docs global url=https://example.invalid/mcp` -2. `/mcp update docs global url=https://example.invalid/v2` -3. 打开 `$MS_AGENT_HOME/mcp.json`:docs 的 url 为 v2。 -4. 刷新 WebUI 设置 → MCP:同一条 docs,URL 已改。 - -### 4.2 Skills remove - -1. 准备一个带 `SKILL.md` 的临时目录,TUI:`/skills add global` -2. 确认 `$MS_AGENT_HOME/skills//SKILL.md` 存在;WebUI 技能页能看到。 -3. TUI:`/skills remove global` -4. 该 managed 目录应已删除;**原始临时目录还在**。WebUI 刷新后这条托管技能消失。 -5. 对自动发现/只读技能:`/skills remove` 应提示 disable instead,不得 `rmtree` 用户仓库里的技能目录。 - -**失败:** update 没改盘;remove 把导入源目录也删了;或删掉了 `.agents/skills` 自动发现项。 - ---- - -## 第 5 期:记忆开关(PersonalizationSettings + 项目开关) - -两端两套开关,语义与 WebUI 相同: - -1. **全局默认** `$MS_AGENT_HOME/settings.json` → `personalization.memory_enabled` / `memory_backend`:只影响**新打开的文件夹**。 -2. **当前项目** `/projects//.ms_agent/project.json` 的 `memory_enabled`:真正往 agent 配置注入 `memory.unified_memory`(file 后端 → `/.ms_agent/memory/MEMORY.md`)。 - -``` -/memory -/memory on|off -/memory project on|off -/memory global on|off -/memory backend file|vector -/memory project backend file|vector -``` - -TUI 的 vector/mem0 不在本期接;选 vector 只落盘给 WebUI 用,**不会**悄悄改写成 file。开关和后端在同一会话的下一条用户消息生效,不需要 `/new`。 - -### 5.1 全局默认 → 新项目 - -1. TUI:`/memory global on` -2. `settings.json` 的 `personalization.memory_enabled` 为 true。 -3. 刷新 WebUI 个性化:默认记忆开。 -4. 用**尚未登记**的新目录再开一次 TUI:该项目 `memory_enabled` 应为 true(已有项目不受全局开关改写)。 - -### 5.2 项目开关 → 本会话 - -1. 同一工作目录。TUI:`/memory on`(或 `/memory project on`) -2. 项目 meta 里 `memory_enabled` 为 true。 -3. 下一条用户消息再请模型「记住我喜欢快排」之类;应能走到 `unified_memory` 工具(file 后端)。不需要 `/new`。 -4. 刷新 WebUI 该项目记忆开关:应为开;`MEMORY.md` 若已写入,WebUI 记忆页能看到。 - -### 5.3 WebUI → TUI - -1. WebUI 打开同一项目,打开记忆(file)。 -2. 新开 TUI 同一 `--work-dir`:`/memory` 项目应为 on;配置里有 `memory.unified_memory`。 - -**失败:** 只改了全局 settings、当前项目对话仍没有记忆工具;或 TUI 把 vector 项目悄悄写成了 MEMORY.md。 - ---- - -## 后续 - -内置技能 `update-config` 先不做成两端技能页都能看到的管理项。配置说明走 `ms_agent/prompting` 里已有的提示词(文件位置、下一轮生效),用来关掉「管理界面看不到、对话里却在用」这一条。 - -更完整的 harness 说明以后用文档技能做,不继续往系统提示词里加手册。 From 2f72cd8de0e10b96174488e85b944b4aee32374e Mon Sep 17 00:00:00 2001 From: suluyan Date: Thu, 15 Oct 2026 15:36:44 +0800 Subject: [PATCH 08/21] fix(tui): tell a rejected model id apart from a model that cannot chat Keep the five chat-unsupported phrases unchanged, and drop the live-list count of filtered image, video, and embedding ids. Co-authored-by: Cursor --- ms_agent/command/builtin/config_cmds.py | 8 ++--- ms_agent/llm/model_discovery.py | 27 ++++++++++++++ ms_agent/tui/app.py | 7 +++- tests/command/test_new_cmds.py | 2 +- tests/llm/test_model_discovery.py | 48 +++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/ms_agent/command/builtin/config_cmds.py b/ms_agent/command/builtin/config_cmds.py index 1dfde3dac..e6012c591 100644 --- a/ms_agent/command/builtin/config_cmds.py +++ b/ms_agent/command/builtin/config_cmds.py @@ -455,18 +455,14 @@ def _provider_status_lines(mgr, *, live: bool = False, 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) + 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: - drop_note = '' - if dropped: - drop_note = ( - f' (dropped {dropped} image/video/embedding)') - lines.append(f' live: {len(live_ids)} chat{drop_note}') + 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: diff --git a/ms_agent/llm/model_discovery.py b/ms_agent/llm/model_discovery.py index 117e225bc..d2ee18365 100644 --- a/ms_agent/llm/model_discovery.py +++ b/ms_agent/llm/model_discovery.py @@ -82,6 +82,22 @@ def wire_protocol(protocol_or_transport: str) -> str: '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', +) + +# "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: @@ -165,6 +181,17 @@ def chat_failed_because_unsupported(model_id: str, error: str = '') -> bool: 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 diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index c8fc1a37d..d7807d991 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -844,11 +844,16 @@ async def _serve(self) -> None: # 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 '' - if chat_failed_because_unsupported(model, str(e)): + 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( '[dim](turn failed — session still open, ' '/model to switch, /quit to exit)[/]') diff --git a/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py index 788cf2283..16006610d 100644 --- a/tests/command/test_new_cmds.py +++ b/tests/command/test_new_cmds.py @@ -540,7 +540,7 @@ def fake(url, protocol, api_key): assert ' qwen-vl-max' in result.content assert 'wanx-v1' not in result.content assert 'text-embedding-v3' not in result.content - assert 'dropped 2' 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'] diff --git a/tests/llm/test_model_discovery.py b/tests/llm/test_model_discovery.py index 29e91925b..e57972881 100644 --- a/tests/llm/test_model_discovery.py +++ b/tests/llm/test_model_discovery.py @@ -175,6 +175,54 @@ def test_probe_timeout_is_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 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() From 1eb86a275aeacfcbdc948ae72f4f2c5bfe5ff424 Mon Sep 17 00:00:00 2001 From: suluyan Date: Thu, 15 Oct 2026 17:05:32 +0800 Subject: [PATCH 09/21] fix(tui): wait for a key after a clear the same way as at startup A cleared key left the old client in place, then failed the turn. Drop that client and keep the session on the setup prompt until a new key exists. Co-authored-by: Cursor --- ms_agent/agent/llm_agent.py | 79 ++++++++++++-- ms_agent/llm/credentials.py | 24 +++++ ms_agent/tui/app.py | 5 +- ms_agent/tui/live_config.py | 50 +++++++-- tests/command/test_interactive_input.py | 82 ++++++++++++++ tests/tui/test_live_config.py | 138 ++++++++++++++++++++++++ 6 files changed, 364 insertions(+), 14 deletions(-) diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index b10ce27c6..fb57eace5 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -2467,19 +2467,32 @@ def _emit_credential_setup(self, exc: BaseException) -> None: else: logger.warning(text) + 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 is_missing_api_key_error + 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 while True: try: self.prepare_llm() if self.runtime is not None: self.runtime.llm = self.llm + self._bind_memory_llm() return messages - except ValueError as e: - if not (self._interactive and is_missing_api_key_error(e)): + except Exception as e: + if not (self._interactive and client_rejected_missing_key(e)): raise self._stub_llm_for_setup() if self.runtime is not None: @@ -2500,6 +2513,48 @@ async def _ensure_llm_ready(self, messages): 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.""" @@ -2766,11 +2821,11 @@ 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() - from ms_agent.llm.credentials import is_missing_api_key_error + from ms_agent.llm.credentials import client_rejected_missing_key try: self.prepare_llm() - except ValueError as e: - if not (self._interactive and is_missing_api_key_error(e)): + 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) @@ -2919,6 +2974,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. @@ -3015,6 +3077,11 @@ async def run_loop(self, messages: Union[List[Message], str], 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 # New user turn (interactive multi-turn): attach the durable # augmentations BEFORE the slice below persists them — same # semantics as the round-0 attach. Order: state notice first diff --git a/ms_agent/llm/credentials.py b/ms_agent/llm/credentials.py index 9131e7f15..67c63c614 100644 --- a/ms_agent/llm/credentials.py +++ b/ms_agent/llm/credentials.py @@ -35,6 +35,30 @@ def is_missing_api_key_error(exc: BaseException) -> bool: 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.') + + def missing_api_key_setup_text(exc: BaseException) -> str: return ( f'{exc}\n\n' diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index d7807d991..9e36f39a2 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -742,8 +742,9 @@ async def _setup_until_ready(self) -> Optional[str]: return None try: rebuilt = LLM.from_config(self.agent.config) - except ValueError as exc: - if self._is_missing_api_key(exc): + except Exception as exc: + from ms_agent.llm.credentials import client_rejected_missing_key + if client_rejected_missing_key(exc): self.console.print( Panel( self._credential_setup_text(exc), diff --git a/ms_agent/tui/live_config.py b/ms_agent/tui/live_config.py index a644cf89c..9393593db 100644 --- a/ms_agent/tui/live_config.py +++ b/ms_agent/tui/live_config.py @@ -48,27 +48,65 @@ def _install_llm(agent, rebuilt) -> 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 or getattr(agent.llm, '_setup_stub', False): + 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: + 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 - 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) + _point_memory_at_llm(agent) def _emit_once(agent, key: str, text: str) -> None: diff --git a/tests/command/test_interactive_input.py b/tests/command/test_interactive_input.py index 340d996ff..2c3eee411 100644 --- a/tests/command/test_interactive_input.py +++ b/tests/command/test_interactive_input.py @@ -319,3 +319,85 @@ def boom(): 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_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/tui/test_live_config.py b/tests/tui/test_live_config.py index bc16b7c12..51eece464 100644 --- a/tests/tui/test_live_config.py +++ b/tests/tui/test_live_config.py @@ -70,6 +70,144 @@ async def test_unchanged_token_does_not_rebuild(): 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( From 472d0203078ec65867812432fee760d3adb5f8d2 Mon Sep 17 00:00:00 2001 From: suluyan Date: Fri, 16 Oct 2026 10:26:50 +0800 Subject: [PATCH 10/21] fix(tui): give a fresh install the same search tool as WebUI Slash commands were still reading config off Runtime, which has no such field, so /skills crashed and search never applied in-process. The next user message already reloads the ledger; a new TUI home now starts with Tavily search enabled. Co-authored-by: Cursor --- ms_agent/command/builtin/memory_cmds.py | 14 ++++++---- ms_agent/command/builtin/resource_cmds.py | 25 ------------------ ms_agent/command/builtin/search_cmds.py | 17 ------------ ms_agent/config/search_settings.py | 9 +++++++ ms_agent/tui/app.py | 2 ++ tests/command/test_memory_cmds.py | 6 ++--- tests/command/test_resource_cmds.py | 13 ++++++++++ tests/command/test_search_cmds.py | 14 ++++++---- tests/e2e/test_tui_webui_align.py | 12 +++++---- tests/tui/test_live_config.py | 22 ++++++++++++++++ tests/tui/test_tui_config_align.py | 26 +++++++++++++++++++ .../backend/app/backends/ms_agent/defaults.py | 4 ++- 12 files changed, 103 insertions(+), 61 deletions(-) diff --git a/ms_agent/command/builtin/memory_cmds.py b/ms_agent/command/builtin/memory_cmds.py index a5614872d..ae7f8fecf 100644 --- a/ms_agent/command/builtin/memory_cmds.py +++ b/ms_agent/command/builtin/memory_cmds.py @@ -93,13 +93,17 @@ def _arm_memory(ctx: CommandContext) -> None: 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) - agent = ctx.runtime - cfg = getattr(agent, 'config', None) if agent is not None else None - if cfg is None: - return '已保存。' - kind = apply_project_memory(cfg, project) + kind = apply_project_memory(OmegaConf.create({}), project) if kind == 'vector-unavailable': return ( '已保存 vector。当前会话不会启动它。' diff --git a/ms_agent/command/builtin/resource_cmds.py b/ms_agent/command/builtin/resource_cmds.py index 0f010fc9d..a14f05545 100644 --- a/ms_agent/command/builtin/resource_cmds.py +++ b/ms_agent/command/builtin/resource_cmds.py @@ -300,7 +300,6 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: if not tokens: return arg_error('/mcp json ', ctx=ctx) n = mgr.import_cursor_format(tokens[0]) - await _reload_mcp(ctx) return CommandResult( type=CommandResultType.MESSAGE, content=f'Imported {n} server(s).', @@ -326,7 +325,6 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: except KeyError as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - await _reload_mcp(ctx) return CommandResult( type=CommandResultType.MESSAGE, content=f'{action} {name} ({scope}).', @@ -350,7 +348,6 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: content='Need command= or url=.', ) mgr.add(name, _stdio_fields(fields), scope=scope) - await _reload_mcp(ctx) lines = [f'Added {name} ({scope}).'] if scope == 'project' and not explicit: lines.append(_PROJECT_MCP_NOT_ON_SETTINGS) @@ -376,7 +373,6 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: except KeyError as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - await _reload_mcp(ctx) return CommandResult( type=CommandResultType.MESSAGE, content=f'Updated {name} ({scope}).', @@ -390,12 +386,6 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: ) -async def _reload_mcp(ctx: CommandContext) -> str: - """Ledger is already written. The next user message applies it.""" - del ctx - return '' - - async def cmd_skills(ctx: CommandContext) -> CommandResult: arg = (ctx.args or '').strip() home, work = _home_work(ctx) @@ -440,7 +430,6 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: skill_id = ' '.join(rest) mgr.set_skill_enabled( skill_id, action == 'enable', scope=scope, project_path=work) - _resync_skills(ctx, mgr, home, work) return CommandResult( type=CommandResultType.MESSAGE, content=f'{action} {skill_id} ({scope}).', @@ -467,7 +456,6 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: type=CommandResultType.MESSAGE, content='No SKILL.md found under that path.', ) - _resync_skills(ctx, mgr, home, work) return CommandResult( type=CommandResultType.MESSAGE, content='Imported: ' + ', '.join(names) + f' ({scope}).', @@ -491,7 +479,6 @@ async def cmd_skills(ctx: CommandContext) -> CommandResult: except (FileNotFoundError, ValueError) as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - _resync_skills(ctx, mgr, home, work) return CommandResult( type=CommandResultType.MESSAGE, content=f'Removed managed skill {skill_id} ({dest}).', @@ -637,18 +624,6 @@ def _list_skills_from_disk(mgr, work: str | None) -> CommandResult: return _format_skill_rows(rows) -def _resync_skills(ctx, mgr, home: str, work: str | None) -> None: - agent = ctx.runtime - if agent is None: - return - from ms_agent.tui.managed_config import merge_skills_into_config - merge_skills_into_config(agent.config, home, work) - runtime = getattr(agent, '_skill_runtime', None) - if runtime is not None: - runtime.sync_with_config(getattr(agent.config, 'skills', None)) - runtime.reload_all() - - 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 index 88844b063..ac59b0482 100644 --- a/ms_agent/command/builtin/search_cmds.py +++ b/ms_agent/command/builtin/search_cmds.py @@ -51,19 +51,6 @@ def _status_text(mgr) -> str: return '\n'.join(lines) -def _apply_runtime(ctx: CommandContext, mgr) -> None: - """Write the saved block onto the live config. The next message reconnects.""" - agent = ctx.runtime - cfg = getattr(agent, 'config', None) if agent is not None else None - if cfg is None: - return - try: - from omegaconf import OmegaConf - OmegaConf.update(cfg, 'tools.web_search', mgr.raw_block(), merge=True) - except Exception: - return - - async def cmd_search(ctx: CommandContext) -> CommandResult: arg = (ctx.args or '').strip() mgr = _mgr() @@ -96,14 +83,12 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: if action in ('enable', 'on'): mgr.set_enabled(True) - _apply_runtime(ctx, mgr) return CommandResult( type=CommandResultType.MESSAGE, content='Search enabled.', ) if action in ('disable', 'off'): mgr.set_enabled(False) - _apply_runtime(ctx, mgr) return CommandResult( type=CommandResultType.MESSAGE, content='Search disabled.', @@ -118,7 +103,6 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: except ValueError as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - _apply_runtime(ctx, mgr) return CommandResult( type=CommandResultType.MESSAGE, content=f'Engine → {cur.engine}.', @@ -134,7 +118,6 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: except ValueError as exc: return CommandResult( type=CommandResultType.MESSAGE, content=str(exc)) - _apply_runtime(ctx, mgr) verb = 'cleared' if clear else 'saved' return CommandResult( type=CommandResultType.MESSAGE, diff --git a/ms_agent/config/search_settings.py b/ms_agent/config/search_settings.py index f604bbc5c..72c7e027d 100644 --- a/ms_agent/config/search_settings.py +++ b/ms_agent/config/search_settings.py @@ -24,6 +24,15 @@ _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: diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index 9e36f39a2..149c4a3c9 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -32,6 +32,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 @@ -52,6 +53,7 @@ '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. diff --git a/tests/command/test_memory_cmds.py b/tests/command/test_memory_cmds.py index 71e7d5c7b..5d0873e69 100644 --- a/tests/command/test_memory_cmds.py +++ b/tests/command/test_memory_cmds.py @@ -73,9 +73,9 @@ async def test_project_toggle_writes_meta_and_config( project = ProjectManager(base_dir=str(isolate_home)).find_by_path( str(work)) assert project.memory_enabled is True - node = OmegaConf.select(runtime.config, 'memory.unified_memory') - assert node is not None - assert node.storage.backend == 'file' + 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 == [] diff --git a/tests/command/test_resource_cmds.py b/tests/command/test_resource_cmds.py index 8a802fd39..13574757a 100644 --- a/tests/command/test_resource_cmds.py +++ b/tests/command/test_resource_cmds.py @@ -364,6 +364,19 @@ def choose(labels, header): 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): diff --git a/tests/command/test_search_cmds.py b/tests/command/test_search_cmds.py index f30408924..2926ec410 100644 --- a/tests/command/test_search_cmds.py +++ b/tests/command/test_search_cmds.py @@ -2,7 +2,6 @@ import json import pytest -from omegaconf import OmegaConf from ms_agent.command.builtin import register_builtin_commands from ms_agent.command.router import CommandRouter @@ -98,8 +97,13 @@ async def test_disable(self, isolate_home): assert data['tools']['web_search']['enabled'] is False @pytest.mark.asyncio - async def test_updates_runtime_config(self): - runtime = type('R', (), {'config': OmegaConf.create({})})() - await make_router().dispatch( + 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 runtime.config.tools.web_search.engine == 'serpapi' + 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/e2e/test_tui_webui_align.py b/tests/e2e/test_tui_webui_align.py index 1fdd58c7b..d862502f1 100644 --- a/tests/e2e/test_tui_webui_align.py +++ b/tests/e2e/test_tui_webui_align.py @@ -500,8 +500,9 @@ def test_g2_g4_project_toggle_webui_and_config( project = ProjectManager(base_dir=str(isolated_home)).find_by_path( str(work_dir)) assert project.memory_enabled is True - node = OmegaConf.select(rt.config, 'memory.unified_memory') - assert node is not None + 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 @@ -542,13 +543,14 @@ def test_g6_webui_enables_project_memory_for_tui( status = run_slash('/memory', runtime_for(work_dir)) assert 'Project: on' in status.content - def test_g7_disable_after_load_asks_for_new(self, isolated_home, - work_dir): + 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 '/new' in result.content + assert 'Project memory → off' in result.content + assert '/new' not in result.content + assert rt._memory_pending is True class TestHttpEnvelope: diff --git a/tests/tui/test_live_config.py b/tests/tui/test_live_config.py index 51eece464..ed330b8b0 100644 --- a/tests/tui/test_live_config.py +++ b/tests/tui/test_live_config.py @@ -302,6 +302,28 @@ def get_server(self, name): assert agent.notices == ['MCP 服务器 fetch 还是连不上。'] +@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)) diff --git a/tests/tui/test_tui_config_align.py b/tests/tui/test_tui_config_align.py index 29781b1d4..b514d20a4 100644 --- a/tests/tui/test_tui_config_align.py +++ b/tests/tui/test_tui_config_align.py @@ -102,12 +102,38 @@ async def _connect(): 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 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 From 1e1396eb9cd8214197a7ff0647055b0c74654267 Mon Sep 17 00:00:00 2001 From: suluyan Date: Fri, 16 Oct 2026 13:55:36 +0800 Subject: [PATCH 11/21] fix(tui): skip an MCP server that does not answer within 6 seconds Probe the configured servers together before a turn, leave the unreachable ones out of that turn, and reap a connect that is still open on quit. Co-authored-by: Cursor --- ms_agent/agent/llm_agent.py | 6 ++ ms_agent/command/builtin/resource_cmds.py | 42 +++++++- ms_agent/mcp/runtime.py | 101 ++++++++++++++----- ms_agent/tools/mcp_client.py | 117 ++++++++++++++++++++-- ms_agent/tui/live_config.py | 43 +++++++- tests/command/test_resource_cmds.py | 61 +++++++++++ tests/mcp/test_mcp_runtime.py | 94 ++++++++++++++++- tests/tools/test_mcp_teardown.py | 35 +++++++ tests/tui/test_live_config.py | 64 ++++++++++++ 9 files changed, 521 insertions(+), 42 deletions(-) diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index fb57eace5..e28e14c04 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -1020,6 +1020,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 = { @@ -2584,6 +2588,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': diff --git a/ms_agent/command/builtin/resource_cmds.py b/ms_agent/command/builtin/resource_cmds.py index a14f05545..993adc5f2 100644 --- a/ms_agent/command/builtin/resource_cmds.py +++ b/ms_agent/command/builtin/resource_cmds.py @@ -128,7 +128,27 @@ def _need_work(scope: str, work: str | None) -> CommandResult | None: return None -def _mcp_list_text(mgr, scope: str) -> str: +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.' @@ -136,7 +156,13 @@ def _mcp_list_text(mgr, scope: str) -> str: 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 '?' - lines.append(f' [{on}] {name} {how}') + 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) @@ -238,9 +264,14 @@ def _mcp_get(ctx, tokens: list[str], mgr, work: str | None) -> CommandResult: 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='\n\n'.join(blocks), + content=content, ) @@ -252,7 +283,8 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: if not arg: return CommandResult( type=CommandResultType.MESSAGE, - content=status_then_usage(_mcp_list_text(mgr, 'merged'), _mcp_usage()), + 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()) @@ -290,7 +322,7 @@ async def cmd_mcp(ctx: CommandContext) -> CommandResult: note = f'名叫 {scope} 的服务器不在这份清单里。\n' return CommandResult( type=CommandResultType.MESSAGE, - content=note + _mcp_list_text(mgr, scope), + content=note + _mcp_list_text(mgr, scope, ctx), ) if action == 'get': diff --git a/ms_agent/mcp/runtime.py b/ms_agent/mcp/runtime.py index 2dc6d77de..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 @@ -126,6 +126,24 @@ 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, @@ -192,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' @@ -204,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.""" @@ -228,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, @@ -256,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 ─────────────────────────────────────────────── @@ -366,6 +418,7 @@ 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 @@ -376,13 +429,15 @@ async def apply_config(self, 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()) diff --git a/ms_agent/tools/mcp_client.py b/ms_agent/tools/mcp_client.py index 06f81892d..83f208088 100644 --- a/ms_agent/tools/mcp_client.py +++ b/ms_agent/tools/mcp_client.py @@ -24,6 +24,21 @@ logger = get_logger() +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}' @@ -54,6 +69,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. @@ -185,6 +205,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: @@ -328,17 +354,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): @@ -359,6 +394,7 @@ async def connect_single_server( server_name=server_name, env=env_dict, timeout=timeout, + startup_timeout=startup_timeout, **server, ) @@ -523,12 +559,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, { @@ -536,8 +575,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 @@ -552,6 +593,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, @@ -560,31 +602,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 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]: @@ -697,16 +748,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): - try: - await self.disconnect_server(name) - except (asyncio.CancelledError, Exception): # noqa: BLE001 - logger.debug('MCP disconnect %s during cleanup', name, - exc_info=True) - with suppress(BaseException): - 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/tui/live_config.py b/ms_agent/tui/live_config.py index 9393593db..9d0d1ac6b 100644 --- a/ms_agent/tui/live_config.py +++ b/ms_agent/tui/live_config.py @@ -231,6 +231,42 @@ def _say_mcp_still_down(agent, names) -> None: _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: @@ -257,13 +293,18 @@ async def _sync_mcp(agent) -> None: 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, 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) diff --git a/tests/command/test_resource_cmds.py b/tests/command/test_resource_cmds.py index 13574757a..5c6a641c3 100644 --- a/tests/command/test_resource_cmds.py +++ b/tests/command/test_resource_cmds.py @@ -33,6 +33,28 @@ def make_ctx(text, runtime=None, extra=None): ) +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({})) @@ -166,6 +188,45 @@ async def test_list_rejects_an_extra_name(self, isolate_home): 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): diff --git a/tests/mcp/test_mcp_runtime.py b/tests/mcp/test_mcp_runtime.py index ac17904be..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]: @@ -698,6 +699,95 @@ class _Config: 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() diff --git a/tests/tools/test_mcp_teardown.py b/tests/tools/test_mcp_teardown.py index bbd8eec64..a671284bc 100644 --- a/tests/tools/test_mcp_teardown.py +++ b/tests/tools/test_mcp_teardown.py @@ -92,6 +92,41 @@ async def stop(self): 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 diff --git a/tests/tui/test_live_config.py b/tests/tui/test_live_config.py index ed330b8b0..8dd9f0a6e 100644 --- a/tests/tui/test_live_config.py +++ b/tests/tui/test_live_config.py @@ -302,6 +302,70 @@ def get_server(self, name): 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' From c65065118cdf84430497c8968aa010e7fda17e79 Mon Sep 17 00:00:00 2001 From: suluyan Date: Fri, 16 Oct 2026 16:04:51 +0800 Subject: [PATCH 12/21] fix(tui): stop reprinting a tall reply and restore paste and arrow keys Live was redrawing the whole reply and still writing when the prompt took the terminal. Stop it first, typeset once, and split a multiline paste into separate commands. Co-authored-by: Cursor --- ms_agent/tui/app.py | 6 +- ms_agent/tui/input.py | 58 +++++++++++++- ms_agent/tui/renderer.py | 85 +++++++++++++++----- tests/tui/test_live_terminal.py | 138 ++++++++++++++++++++++++++++++++ tests/tui/test_renderer.py | 107 ++++++++++++++++++++++++- tests/tui/test_tui_input.py | 63 ++++++++++++++- 6 files changed, 431 insertions(+), 26 deletions(-) create mode 100644 tests/tui/test_live_terminal.py diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index 149c4a3c9..496627c77 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -161,7 +161,11 @@ async def _on_user_turn(messages): 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 diff --git a/ms_agent/tui/input.py b/ms_agent/tui/input.py index 64232e7e0..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' @@ -138,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/renderer.py b/ms_agent/tui/renderer.py index d491b6550..9a3415cc8 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 @@ -122,15 +122,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 +191,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) ───────────────────────────────────── 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..008ab5f17 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,106 @@ 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_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_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 From 462bdc4996283e096b19caff7006d12892eda264 Mon Sep 17 00:00:00 2001 From: suluyan Date: Fri, 16 Oct 2026 17:25:38 +0800 Subject: [PATCH 13/21] fix(tui): ignore unknown tool args and retry after a key is set A shell call was failing on description, and a short tool name was only reported. The default model id is one the endpoint still lists, a rejected id uses the existing unavailable-model sentence, and the missing-key note is shown once then the held sentence runs after the key is configured. Co-authored-by: Cursor --- docs/en/Components/Config.md | 2 +- docs/zh/Components/config.md | 2 +- ms_agent/agent/agent.yaml | 2 +- ms_agent/agent/llm_agent.py | 21 +++++- ms_agent/command/interactive.py | 11 +++ ms_agent/llm/model_discovery.py | 1 + ms_agent/tools/code/code_executor.py | 3 +- ms_agent/tools/code/local_code_executor.py | 23 +++++++ ms_agent/tools/tool_manager.py | 40 ++++++++--- ms_agent/tui/app.py | 78 ---------------------- tests/command/test_interactive_input.py | 46 +++++++++++++ tests/llm/test_model_discovery.py | 3 + tests/llm/test_provider_router_live.py | 2 +- tests/tools/test_shell_environment.py | 29 ++++++++ tests/tools/test_tool_name_and_reread.py | 27 ++++++++ tests/tui/test_tui_config_align.py | 24 +------ 16 files changed, 197 insertions(+), 117 deletions(-) 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/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/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/llm_agent.py b/ms_agent/agent/llm_agent.py index e28e14c04..86d49f257 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -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( @@ -2464,6 +2467,11 @@ def _stub_llm_for_setup(self) -> None: 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 text = missing_api_key_setup_text(exc) if self._event_sink is not None: @@ -2471,6 +2479,15 @@ def _emit_credential_setup(self, exc: BaseException) -> None: else: logger.warning(text) + 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 []): @@ -2510,7 +2527,9 @@ async def _ensure_llm_ready(self, messages): input_source=self._input_source, event_sink=self._event_sink) turn = await session.run_turn( - messages=None, runtime=self.runtime) + messages=None, + runtime=self.runtime, + retry_when=self._llm_credentials_present) if turn.action == 'quit': self.runtime.should_stop = True return None diff --git a/ms_agent/command/interactive.py b/ms_agent/command/interactive.py index 6ae068a96..31aa5c85a 100644 --- a/ms_agent/command/interactive.py +++ b/ms_agent/command/interactive.py @@ -77,6 +77,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. @@ -148,6 +150,15 @@ async def run_turn( 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.""" diff --git a/ms_agent/llm/model_discovery.py b/ms_agent/llm/model_discovery.py index d2ee18365..3dd480a53 100644 --- a/ms_agent/llm/model_discovery.py +++ b/ms_agent/llm/model_discovery.py @@ -89,6 +89,7 @@ def wire_protocol(protocol_or_transport: str) -> str: _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 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 ce5e7ebe7..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 @@ -718,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/tool_manager.py b/ms_agent/tools/tool_manager.py index f2c36d781..abbd36cd5 100644 --- a/ms_agent/tools/tool_manager.py +++ b/ms_agent/tools/tool_manager.py @@ -55,6 +55,7 @@ def _must_propagate(exc: BaseException) -> bool: return False return not isinstance(exc, Exception) + 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)) @@ -445,26 +446,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 diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index 496627c77..4227071a3 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -27,7 +27,6 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table -from types import SimpleNamespace from typing import Optional, Tuple from ms_agent.config import Config @@ -701,69 +700,6 @@ def _quiet_logs() -> None: for h in lg.handlers: h.setLevel(logging.ERROR) - @staticmethod - def _is_missing_api_key(exc: BaseException) -> bool: - from ms_agent.llm.credentials import is_missing_api_key_error - return is_missing_api_key_error(exc) - - @staticmethod - def _credential_setup_text(exc: BaseException) -> str: - from ms_agent.llm.credentials import missing_api_key_setup_text - return missing_api_key_setup_text(exc) - - def _ensure_command_runtime(self) -> None: - """Let slash commands run after prepare_llm failed (no live LLM yet).""" - from ms_agent.agent.runtime import Runtime - cfg = self.agent.config - llm = getattr(self.agent, 'llm', None) - if llm is None: - llm = SimpleNamespace( - config=cfg, - model=str( - OmegaConf.select(cfg, 'llm.model', default='') or ''), - _setup_stub=True, - ) - self.agent.llm = llm - if getattr(self.agent, 'runtime', None) is None: - self.agent.runtime = Runtime(llm=llm) - elif getattr(self.agent.runtime, 'llm', None) is None: - self.agent.runtime.llm = llm - - async def _setup_until_ready(self) -> Optional[str]: - """Prompt until credentials work or the user quits. Returns first chat.""" - from ms_agent.command.interactive import InteractiveSession - from ms_agent.llm import LLM - - self._ensure_command_runtime() - session = InteractiveSession( - self.router, - source='tui', - input_source=self.input, - event_sink=self.renderer, - ) - while True: - turn = await session.run_turn( - messages=None, runtime=self.agent.runtime) - if turn.action == 'quit': - return None - try: - rebuilt = LLM.from_config(self.agent.config) - except Exception as exc: - from ms_agent.llm.credentials import client_rejected_missing_key - if client_rejected_missing_key(exc): - self.console.print( - Panel( - self._credential_setup_text(exc), - title='[yellow]still no API key[/]', - border_style='yellow', - expand=False)) - continue - raise - self.agent.llm = rebuilt - if self.agent.runtime is not None: - self.agent.runtime.llm = rebuilt - return turn.text or '' - # -- main loop (route A: one lifecycle per session) -- async def _serve(self) -> None: @@ -829,20 +765,6 @@ async def _serve(self) -> None: # request to leave. cleanup_tools already swallows it on # /quit; if it still surfaces mid-turn, stay in the session. self.renderer.finalize() - if self._is_missing_api_key(e): - logger.info('TUI waiting for API key: %s', e) - self.console.print( - Panel( - self._credential_setup_text(e), - title='[yellow]setup[/]', - border_style='yellow', - expand=False)) - queued = await self._setup_until_ready() - if queued is None: - break - self._queued_query = queued - resume = False - continue 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 diff --git a/tests/command/test_interactive_input.py b/tests/command/test_interactive_input.py index 2c3eee411..f96864941 100644 --- a/tests/command/test_interactive_input.py +++ b/tests/command/test_interactive_input.py @@ -320,6 +320,52 @@ def 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 + + 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 + + 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 'No API key found' 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 diff --git a/tests/llm/test_model_discovery.py b/tests/llm/test_model_discovery.py index e57972881..ccd9c6510 100644 --- a/tests/llm/test_model_discovery.py +++ b/tests/llm/test_model_discovery.py @@ -216,6 +216,9 @@ def test_rejected_model_id_is_separate_from_cannot_chat(): 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') 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/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_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_tui_config_align.py b/tests/tui/test_tui_config_align.py index b514d20a4..ad07ccdb5 100644 --- a/tests/tui/test_tui_config_align.py +++ b/tests/tui/test_tui_config_align.py @@ -336,36 +336,14 @@ def test_missing_api_key_is_classed_as_setup_not_fatal(): missing_api_key_setup_text, ) err = ValueError('No API key found for provider "modelscope"') - assert TuiApp._is_missing_api_key(err) assert is_missing_api_key_error(err) - assert not TuiApp._is_missing_api_key(ValueError('boom')) + 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 '/model provider key' in text assert '/quit' in text -@pytest.mark.asyncio -async def test_setup_until_ready_quit_returns_none(): - from ms_agent.command import CommandRouter, register_builtin_commands - from ms_agent.agent.runtime import Runtime - - app = TuiApp.__new__(TuiApp) - cfg = OmegaConf.create({'llm': {'model': 'm', 'service': 'modelscope'}}) - router = CommandRouter() - register_builtin_commands(router) - app.agent = SimpleNamespace( - config=cfg, llm=None, runtime=Runtime(llm=None)) - app.router = router - app.input = None - app.renderer = None - app.console = Console(file=io.StringIO()) - - with patch('builtins.input', return_value='/quit'): - queued = await app._setup_until_ready() - assert queued is None - - @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 d87161137f5238ab4543a6e8c260bce2b7339f23 Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 19 Oct 2026 14:07:16 +0800 Subject: [PATCH 14/21] fix(tui): name the provider in the missing-key notice The setup text was the exception plus several commands. It now gives the live provider and one key command, and notes when a parked sentence is sent. Co-authored-by: Cursor --- ms_agent/agent/llm_agent.py | 22 ++++++++++++++++++- ms_agent/llm/credentials.py | 28 ++++++++++++++++++------- tests/command/test_interactive_input.py | 13 +++++++++++- tests/e2e/test_tui_webui_usability.py | 7 +++---- tests/tui/test_tui_config_align.py | 4 ++-- 5 files changed, 58 insertions(+), 16 deletions(-) diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 86d49f257..6f31864d2 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -2473,12 +2473,27 @@ def _emit_credential_setup(self, exc: BaseException) -> None: return _credential_notice_sent = True from ms_agent.llm.credentials import missing_api_key_setup_text - text = missing_api_key_setup_text(exc) + 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 @@ -2505,16 +2520,21 @@ async def _ensure_llm_ready(self, messages): 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 diff --git a/ms_agent/llm/credentials.py b/ms_agent/llm/credentials.py index 67c63c614..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 @@ -59,14 +60,25 @@ def missing_api_key_error(config) -> ValueError: f'Set config.llm.{service}_api_key.') -def missing_api_key_setup_text(exc: BaseException) -> str: - return ( - f'{exc}\n\n' - 'The session stays open — configure a key, then send a message.\n' - ' /model provider key \n' - ' /model provider add openai key=... url=...\n' - ' /model openai/\n' - ' /quit to leave') +_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: diff --git a/tests/command/test_interactive_input.py b/tests/command/test_interactive_input.py index f96864941..2889b1a7e 100644 --- a/tests/command/test_interactive_input.py +++ b/tests/command/test_interactive_input.py @@ -334,6 +334,13 @@ def maybe(): 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): @@ -345,6 +352,7 @@ def _input(prompt=''): 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 @@ -362,7 +370,10 @@ def emit(self, event): agent._emit_credential_setup(err) agent._emit_credential_setup(err) assert len(seen) == 1 - assert 'No API key found' in seen[0] + 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 diff --git a/tests/e2e/test_tui_webui_usability.py b/tests/e2e/test_tui_webui_usability.py index d657811dd..27986ddbc 100644 --- a/tests/e2e/test_tui_webui_usability.py +++ b/tests/e2e/test_tui_webui_usability.py @@ -333,13 +333,12 @@ def test_resume_zero_still_selects_first_session(self, isolated_home, class TestMissingKeyPrompt: - def test_setup_text_tells_user_session_stays_open(self): + 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 '/model provider key' in text - assert '/quit' in text - assert 'stays open' in text.lower() or 'session stays' in text.lower() + assert text == ('还没有 openai 的密钥。\n\n' + ' /model provider key openai <密钥>') class TestDocZeroFiveIsUnreasonable: diff --git a/tests/tui/test_tui_config_align.py b/tests/tui/test_tui_config_align.py index ad07ccdb5..26f2fdfab 100644 --- a/tests/tui/test_tui_config_align.py +++ b/tests/tui/test_tui_config_align.py @@ -340,8 +340,8 @@ def test_missing_api_key_is_classed_as_setup_not_fatal(): 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 '/model provider key' in text - assert '/quit' in text + assert text == ('还没有 modelscope 的密钥。\n\n' + ' /model provider key modelscope <密钥>') @pytest.mark.asyncio From 7e4fc3f576f2431552befa6fbcb0c9a99348470e Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 19 Oct 2026 16:34:49 +0800 Subject: [PATCH 15/21] fix(tui): say how to install a search engine that failed to import Keep the key line, and print a pip command when the selected engine's package is missing. /tools marks a local tool that did not connect instead of listing it as ready. Co-authored-by: Cursor --- ms_agent/agent/llm_agent.py | 2 + ms_agent/command/builtin/context_cmds.py | 9 ++- ms_agent/command/builtin/search_cmds.py | 24 +++++++- ms_agent/tools/search/availability.py | 78 ++++++++++++++++++++++++ ms_agent/tools/tool_manager.py | 42 +++++++++++++ ms_agent/tui/renderer.py | 19 +++++- tests/command/test_new_cmds.py | 18 ++++++ tests/command/test_search_cmds.py | 38 ++++++++++++ tests/tui/test_renderer.py | 26 ++++++++ 9 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 ms_agent/tools/search/availability.py diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 6f31864d2..ebef901b5 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -1068,6 +1068,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() diff --git a/ms_agent/command/builtin/context_cmds.py b/ms_agent/command/builtin/context_cmds.py index 8fa8248bd..68029449b 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/search_cmds.py b/ms_agent/command/builtin/search_cmds.py index ac59b0482..9ebfb0e9a 100644 --- a/ms_agent/command/builtin/search_cmds.py +++ b/ms_agent/command/builtin/search_cmds.py @@ -32,12 +32,19 @@ def _mgr(): 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' - if not requires_key(cur.engine): + 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)' @@ -45,9 +52,15 @@ def _status_text(mgr) -> str: 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) @@ -76,6 +89,13 @@ async def cmd_search(ctx: CommandContext) -> CommandResult: 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( 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/tool_manager.py b/ms_agent/tools/tool_manager.py index abbd36cd5..8e803d85c 100644 --- a/ms_agent/tools/tool_manager.py +++ b/ms_agent/tools/tool_manager.py @@ -56,6 +56,40 @@ def _must_propagate(exc: BaseException) -> bool: 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)) @@ -191,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'): @@ -369,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() @@ -376,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, diff --git a/ms_agent/tui/renderer.py b/ms_agent/tui/renderer.py index 9a3415cc8..e0f0ad3f6 100644 --- a/ms_agent/tui/renderer.py +++ b/ms_agent/tui/renderer.py @@ -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|$)') @@ -311,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/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py index 16006610d..492c6c8e8 100644 --- a/tests/command/test_new_cmds.py +++ b/tests/command/test_new_cmds.py @@ -898,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() diff --git a/tests/command/test_search_cmds.py b/tests/command/test_search_cmds.py index 2926ec410..8ec512fdd 100644 --- a/tests/command/test_search_cmds.py +++ b/tests/command/test_search_cmds.py @@ -96,6 +96,44 @@ async def test_disable(self, isolate_home): 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): diff --git a/tests/tui/test_renderer.py b/tests/tui/test_renderer.py index 008ab5f17..2e4871eb1 100644 --- a/tests/tui/test_renderer.py +++ b/tests/tui/test_renderer.py @@ -169,6 +169,32 @@ def _rendered(renderable, width: int) -> str: 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()) From 5608f920c94064ad609351e5ca90f3477785ffeb Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 19 Oct 2026 17:27:25 +0800 Subject: [PATCH 16/21] fix: keep MCP tool calls working on mcp 2.x Read the renamed schema, error, and timeout fields, return image, audio, and structured results, and declare loguru so a clean install can import the agent. Co-authored-by: Cursor --- docs/en/GetStarted/Installation.md | 2 +- docs/zh/GetStarted/installation.md | 2 +- ms_agent/tools/mcp_client.py | 181 ++++++++++++++++++++++++++--- requirements/framework.txt | 3 +- requirements/research.txt | 2 +- tests/tools/test_mcp_teardown.py | 117 ++++++++++++++++++- 6 files changed, 288 insertions(+), 19 deletions(-) diff --git a/docs/en/GetStarted/Installation.md b/docs/en/GetStarted/Installation.md index 7e4acb3a3..5a3b26772 100644 --- a/docs/en/GetStarted/Installation.md +++ b/docs/en/GetStarted/Installation.md @@ -23,7 +23,7 @@ Activate an existing virtual or Conda environment instead if you already have on `pip install ms-agent` installs the published 1.6.0 release, with the older WebUI and dependency set. It lacks the current TUI and the `retrieval` and `cinema` extras. Use source installation below for the capabilities documented in the current repository. -**Older release issue:** The Sirchmunk integration in PyPI 1.6.0 loads the undeclared `loguru` dependency when importing `LLMAgent`, causing the import to fail in a clean environment. The current source uses the framework's own logger and no longer depends directly on `loguru`. New users should install from source. +**Older release issue:** The Sirchmunk integration in PyPI 1.6.0 loads `loguru` when importing `LLMAgent`. That package was not declared then, so a clean install failed. The current source lists `loguru` in `requirements/framework.txt`. New users should install from source. ## Source Code Installation diff --git a/docs/zh/GetStarted/installation.md b/docs/zh/GetStarted/installation.md index b0a53b8e0..df5f74227 100644 --- a/docs/zh/GetStarted/installation.md +++ b/docs/zh/GetStarted/installation.md @@ -23,7 +23,7 @@ source ms-agent-env/bin/activate `pip install ms-agent` 安装 PyPI 已发布的 1.6.0,使用旧版 WebUI 和依赖组合。它没有当前源码的 TUI、`retrieval` 或 `cinema` extras。体验当前仓库介绍的功能,请使用下方源码安装。 -**旧版安装问题:** PyPI 1.6.0 中的 Sirchmunk 集成会在导入 `LLMAgent` 时加载未声明的 `loguru`,导致干净环境中的导入失败。当前源码已改用框架内置日志,不再直接依赖 `loguru`;新用户请优先从源码安装。 +**旧版安装问题:** PyPI 1.6.0 中的 Sirchmunk 集成会在导入 `LLMAgent` 时加载当时未声明的 `loguru`,导致干净环境中的导入失败。当前源码把 `loguru` 列入 `requirements/framework.txt`。新用户请优先从源码安装。 ## 源代码安装 diff --git a/ms_agent/tools/mcp_client.py b/ms_agent/tools/mcp_client.py index 83f208088..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 @@ -24,6 +29,105 @@ 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. @@ -240,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) @@ -259,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, @@ -291,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 ] @@ -475,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: @@ -510,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( diff --git a/requirements/framework.txt b/requirements/framework.txt index 575530bcb..0ff013ded 100644 --- a/requirements/framework.txt +++ b/requirements/framework.txt @@ -3,9 +3,10 @@ croniter>=1.3.0 dotenv httpx>=0.28.1 json5 +loguru 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/tests/tools/test_mcp_teardown.py b/tests/tools/test_mcp_teardown.py index a671284bc..2d56e6016 100644 --- a/tests/tools/test_mcp_teardown.py +++ b/tests/tools/test_mcp_teardown.py @@ -2,11 +2,58 @@ 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 +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(): @@ -143,3 +190,71 @@ async def _boom(): 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'] From d44a220b0230cc168d9ddf7860ad8ab196658bb1 Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 19 Oct 2026 17:57:11 +0800 Subject: [PATCH 17/21] feat(tui): open the terminal UI from a bare ms-agent Insert tui when no subcommand is given, and show only the model id on the welcome line so the provider prefix is not copied into a model field. Co-authored-by: Cursor --- README.md | 2 +- README_ZH.md | 2 +- docs/en/GetStarted/CLI.md | 3 ++- docs/zh/GetStarted/cli.md | 3 ++- ms_agent/cli/cli.py | 19 +++++++++++++-- ms_agent/tui/app.py | 3 +-- tests/cli/test_cli_entry.py | 48 +++++++++++++++++++++++++++++++++++++ 7 files changed, 72 insertions(+), 8 deletions(-) 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/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/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/cli/cli.py b/ms_agent/cli/cli.py index 5182303e8..9957e7800 100644 --- a/ms_agent/cli/cli.py +++ b/ms_agent/cli/cli.py @@ -1,4 +1,5 @@ import argparse +import sys from ms_agent.cli.a2a_cmd import A2ACmd, A2ARegistryCmd from ms_agent.cli.acp_cmd import ACPCmd, ACPRegistryCmd @@ -12,6 +13,19 @@ from ms_agent.cli.ui import UICMD +def _with_default_tui(argv): + """``ms-agent`` with no subcommand is the terminal UI. + + A bare invocation, or one whose first token is an option, is parsed as + ``ms-agent tui ``. 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/tui/app.py b/ms_agent/tui/app.py index 4227071a3..9577cd684 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -630,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}[/]') 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 From 3b2c8756dbf4c707e9b430a6fab85838a929b38d Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 19 Oct 2026 18:19:38 +0800 Subject: [PATCH 18/21] fix: import loguru only when Sirchmunk search starts Keep it out of the framework requirements. A missing package is reported at local-search startup instead of failing the agent import. Co-authored-by: Cursor --- docs/en/GetStarted/Installation.md | 2 +- docs/zh/GetStarted/installation.md | 2 +- ms_agent/tools/search/sirchmunk_search.py | 16 ++++++++++- requirements/framework.txt | 1 - tests/knowledge_search/test_sirschmunk.py | 35 +++++++++++++++++++++++ 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/en/GetStarted/Installation.md b/docs/en/GetStarted/Installation.md index 5a3b26772..103a6d54d 100644 --- a/docs/en/GetStarted/Installation.md +++ b/docs/en/GetStarted/Installation.md @@ -23,7 +23,7 @@ Activate an existing virtual or Conda environment instead if you already have on `pip install ms-agent` installs the published 1.6.0 release, with the older WebUI and dependency set. It lacks the current TUI and the `retrieval` and `cinema` extras. Use source installation below for the capabilities documented in the current repository. -**Older release issue:** The Sirchmunk integration in PyPI 1.6.0 loads `loguru` when importing `LLMAgent`. That package was not declared then, so a clean install failed. The current source lists `loguru` in `requirements/framework.txt`. New users should install from source. +**Older release issue:** The Sirchmunk integration in PyPI 1.6.0 loads the undeclared `loguru` dependency when importing `LLMAgent`, causing the import to fail in a clean environment. The current source uses the framework's own logger and no longer depends directly on `loguru`. `loguru` is imported only when Sirchmunk local search actually starts, and a missing package says to run `pip install loguru`. New users should install from source. ## Source Code Installation diff --git a/docs/zh/GetStarted/installation.md b/docs/zh/GetStarted/installation.md index df5f74227..6a0b83902 100644 --- a/docs/zh/GetStarted/installation.md +++ b/docs/zh/GetStarted/installation.md @@ -23,7 +23,7 @@ source ms-agent-env/bin/activate `pip install ms-agent` 安装 PyPI 已发布的 1.6.0,使用旧版 WebUI 和依赖组合。它没有当前源码的 TUI、`retrieval` 或 `cinema` extras。体验当前仓库介绍的功能,请使用下方源码安装。 -**旧版安装问题:** PyPI 1.6.0 中的 Sirchmunk 集成会在导入 `LLMAgent` 时加载当时未声明的 `loguru`,导致干净环境中的导入失败。当前源码把 `loguru` 列入 `requirements/framework.txt`。新用户请优先从源码安装。 +**旧版安装问题:** PyPI 1.6.0 中的 Sirchmunk 集成会在导入 `LLMAgent` 时加载未声明的 `loguru`,导致干净环境中的导入失败。当前源码已改用框架内置日志,不再直接依赖 `loguru`;只有真正启动 Sirchmunk 本地搜索时才会加载它,未安装则提示 `pip install loguru`。新用户请优先从源码安装。 ## 源代码安装 diff --git a/ms_agent/tools/search/sirchmunk_search.py b/ms_agent/tools/search/sirchmunk_search.py index 9fb0bc8c1..e0d163e3b 100644 --- a/ms_agent/tools/search/sirchmunk_search.py +++ b/ms_agent/tools/search/sirchmunk_search.py @@ -180,6 +180,14 @@ def _initialize_searcher(self): return try: + # loguru belongs to sirchmunk, not the framework. Import it here, + # only when local search actually starts. + try: + import loguru # noqa: F401 + except ImportError as exc: + raise ImportError( + 'loguru is not installed. Sirchmunk needs it. ' + 'Run: pip install loguru') from exc from sirchmunk.llm.openai_chat import OpenAIChat from sirchmunk.search import AgenticSearch from sirchmunk.utils.embedding_util import EmbeddingUtil @@ -218,9 +226,15 @@ def _initialize_searcher(self): ) except ImportError as e: + if 'loguru is not installed' in str(e): + raise + if getattr(e, 'name', None) == 'loguru' or 'loguru' in str(e): + raise ImportError( + 'loguru is not installed. Sirchmunk needs it. ' + 'Run: pip install loguru') from e raise ImportError( f'Failed to import sirchmunk: {e}. ' - 'Please install sirchmunk: pip install sirchmunk') + 'Please install sirchmunk: pip install sirchmunk') from e except Exception as e: raise RuntimeError(f'Failed to initialize SirchmunkSearch: {e}') diff --git a/requirements/framework.txt b/requirements/framework.txt index 0ff013ded..0f7c30be0 100644 --- a/requirements/framework.txt +++ b/requirements/framework.txt @@ -3,7 +3,6 @@ croniter>=1.3.0 dotenv httpx>=0.28.1 json5 -loguru markdown matplotlib mcp diff --git a/tests/knowledge_search/test_sirschmunk.py b/tests/knowledge_search/test_sirschmunk.py index 705c7184e..54b5eb61b 100644 --- a/tests/knowledge_search/test_sirschmunk.py +++ b/tests/knowledge_search/test_sirschmunk.py @@ -6,11 +6,15 @@ python -m pytest tests/knowledge_search/test_sirschmunk.py """ import asyncio +import builtins import os import shutil +import sys import unittest from pathlib import Path +import pytest + from ms_agent.agent import LLMAgent from ms_agent.tools.search.sirchmunk_search import SirchmunkSearch from ms_agent.llm.utils import Message @@ -118,5 +122,36 @@ def test_sirchmunk_search_query_smoke(self): self.assertTrue(len(result) > 0) +def test_missing_loguru_is_named(tmp_path, monkeypatch): + real_import = builtins.__import__ + + def _blocked(name, *args, **kwargs): + if name == 'loguru' or name.startswith('loguru.'): + raise ModuleNotFoundError( + "No module named 'loguru'", name='loguru') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', _blocked) + for name in list(sys.modules): + if name == 'loguru' or name.startswith('loguru.'): + monkeypatch.delitem(sys.modules, name, raising=False) + config = DictConfig({ + 'output_dir': str(tmp_path), + 'llm': { + 'service': 'openai', + 'model': 'gpt', + 'openai_api_key': 'k', + 'openai_base_url': 'https://example.invalid', + }, + 'tools': { + 'localsearch': { + 'paths': [str(tmp_path)], + }, + }, + }) + with pytest.raises(ImportError, match='loguru is not installed'): + SirchmunkSearch(config)._initialize_searcher() + + if __name__ == '__main__': unittest.main() From 63db62e4d75d2c2fd4ff537e817448c5968573b0 Mon Sep 17 00:00:00 2001 From: suluyan Date: Mon, 19 Oct 2026 18:36:12 +0800 Subject: [PATCH 19/21] fix: drop the extra loguru import around Sirchmunk The existing import error already covers a missing dependency, so a separate loguru check is unnecessary. Co-authored-by: Cursor --- docs/en/GetStarted/Installation.md | 2 +- docs/zh/GetStarted/installation.md | 2 +- ms_agent/tools/search/sirchmunk_search.py | 16 +---------- tests/knowledge_search/test_sirschmunk.py | 35 ----------------------- 4 files changed, 3 insertions(+), 52 deletions(-) diff --git a/docs/en/GetStarted/Installation.md b/docs/en/GetStarted/Installation.md index 103a6d54d..7e4acb3a3 100644 --- a/docs/en/GetStarted/Installation.md +++ b/docs/en/GetStarted/Installation.md @@ -23,7 +23,7 @@ Activate an existing virtual or Conda environment instead if you already have on `pip install ms-agent` installs the published 1.6.0 release, with the older WebUI and dependency set. It lacks the current TUI and the `retrieval` and `cinema` extras. Use source installation below for the capabilities documented in the current repository. -**Older release issue:** The Sirchmunk integration in PyPI 1.6.0 loads the undeclared `loguru` dependency when importing `LLMAgent`, causing the import to fail in a clean environment. The current source uses the framework's own logger and no longer depends directly on `loguru`. `loguru` is imported only when Sirchmunk local search actually starts, and a missing package says to run `pip install loguru`. New users should install from source. +**Older release issue:** The Sirchmunk integration in PyPI 1.6.0 loads the undeclared `loguru` dependency when importing `LLMAgent`, causing the import to fail in a clean environment. The current source uses the framework's own logger and no longer depends directly on `loguru`. New users should install from source. ## Source Code Installation diff --git a/docs/zh/GetStarted/installation.md b/docs/zh/GetStarted/installation.md index 6a0b83902..b0a53b8e0 100644 --- a/docs/zh/GetStarted/installation.md +++ b/docs/zh/GetStarted/installation.md @@ -23,7 +23,7 @@ source ms-agent-env/bin/activate `pip install ms-agent` 安装 PyPI 已发布的 1.6.0,使用旧版 WebUI 和依赖组合。它没有当前源码的 TUI、`retrieval` 或 `cinema` extras。体验当前仓库介绍的功能,请使用下方源码安装。 -**旧版安装问题:** PyPI 1.6.0 中的 Sirchmunk 集成会在导入 `LLMAgent` 时加载未声明的 `loguru`,导致干净环境中的导入失败。当前源码已改用框架内置日志,不再直接依赖 `loguru`;只有真正启动 Sirchmunk 本地搜索时才会加载它,未安装则提示 `pip install loguru`。新用户请优先从源码安装。 +**旧版安装问题:** PyPI 1.6.0 中的 Sirchmunk 集成会在导入 `LLMAgent` 时加载未声明的 `loguru`,导致干净环境中的导入失败。当前源码已改用框架内置日志,不再直接依赖 `loguru`;新用户请优先从源码安装。 ## 源代码安装 diff --git a/ms_agent/tools/search/sirchmunk_search.py b/ms_agent/tools/search/sirchmunk_search.py index e0d163e3b..9fb0bc8c1 100644 --- a/ms_agent/tools/search/sirchmunk_search.py +++ b/ms_agent/tools/search/sirchmunk_search.py @@ -180,14 +180,6 @@ def _initialize_searcher(self): return try: - # loguru belongs to sirchmunk, not the framework. Import it here, - # only when local search actually starts. - try: - import loguru # noqa: F401 - except ImportError as exc: - raise ImportError( - 'loguru is not installed. Sirchmunk needs it. ' - 'Run: pip install loguru') from exc from sirchmunk.llm.openai_chat import OpenAIChat from sirchmunk.search import AgenticSearch from sirchmunk.utils.embedding_util import EmbeddingUtil @@ -226,15 +218,9 @@ def _initialize_searcher(self): ) except ImportError as e: - if 'loguru is not installed' in str(e): - raise - if getattr(e, 'name', None) == 'loguru' or 'loguru' in str(e): - raise ImportError( - 'loguru is not installed. Sirchmunk needs it. ' - 'Run: pip install loguru') from e raise ImportError( f'Failed to import sirchmunk: {e}. ' - 'Please install sirchmunk: pip install sirchmunk') from e + 'Please install sirchmunk: pip install sirchmunk') except Exception as e: raise RuntimeError(f'Failed to initialize SirchmunkSearch: {e}') diff --git a/tests/knowledge_search/test_sirschmunk.py b/tests/knowledge_search/test_sirschmunk.py index 54b5eb61b..705c7184e 100644 --- a/tests/knowledge_search/test_sirschmunk.py +++ b/tests/knowledge_search/test_sirschmunk.py @@ -6,15 +6,11 @@ python -m pytest tests/knowledge_search/test_sirschmunk.py """ import asyncio -import builtins import os import shutil -import sys import unittest from pathlib import Path -import pytest - from ms_agent.agent import LLMAgent from ms_agent.tools.search.sirchmunk_search import SirchmunkSearch from ms_agent.llm.utils import Message @@ -122,36 +118,5 @@ def test_sirchmunk_search_query_smoke(self): self.assertTrue(len(result) > 0) -def test_missing_loguru_is_named(tmp_path, monkeypatch): - real_import = builtins.__import__ - - def _blocked(name, *args, **kwargs): - if name == 'loguru' or name.startswith('loguru.'): - raise ModuleNotFoundError( - "No module named 'loguru'", name='loguru') - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, '__import__', _blocked) - for name in list(sys.modules): - if name == 'loguru' or name.startswith('loguru.'): - monkeypatch.delitem(sys.modules, name, raising=False) - config = DictConfig({ - 'output_dir': str(tmp_path), - 'llm': { - 'service': 'openai', - 'model': 'gpt', - 'openai_api_key': 'k', - 'openai_base_url': 'https://example.invalid', - }, - 'tools': { - 'localsearch': { - 'paths': [str(tmp_path)], - }, - }, - }) - with pytest.raises(ImportError, match='loguru is not installed'): - SirchmunkSearch(config)._initialize_searcher() - - if __name__ == '__main__': unittest.main() From 2ae94eef22387c13dd88db6b8f636cd7004daa0f Mon Sep 17 00:00:00 2001 From: suluyan Date: Tue, 20 Oct 2026 16:23:43 +0800 Subject: [PATCH 20/21] fix(tui): mask model keys the same way WebUI does Restore the WebUI prefix mask and use it in /model list. Refresh the WebUI lock so mcp is no longer recorded as mcp<2. Co-authored-by: Cursor --- ms_agent/command/builtin/config_cmds.py | 12 ++++++++++-- tests/command/test_new_cmds.py | 2 +- tests/e2e/test_tui_webui_align.py | 2 +- tests/e2e/test_tui_webui_usability.py | 15 ++++++++------- webui/backend/app/backends/ms_agent/mapping.py | 9 ++------- webui/backend/tests/test_mapping.py | 3 +-- webui/backend/uv.lock | 6 +++--- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/ms_agent/command/builtin/config_cmds.py b/ms_agent/command/builtin/config_cmds.py index e6012c591..4e8530bad 100644 --- a/ms_agent/command/builtin/config_cmds.py +++ b/ms_agent/command/builtin/config_cmds.py @@ -331,9 +331,17 @@ def _switch_fail_text(target_service, target_model, stay_service, def _mask_key(value) -> str: - if not value: + """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' - return 'set' + if len(text) <= 8: + return '****' + return f'{text[:4]}****{text[-4:]}' def _iter_provider_rows(mgr): diff --git a/tests/command/test_new_cmds.py b/tests/command/test_new_cmds.py index 492c6c8e8..26df334b4 100644 --- a/tests/command/test_new_cmds.py +++ b/tests/command/test_new_cmds.py @@ -567,7 +567,7 @@ async def test_provider_add_set_key_catalog(self, tmp_path, monkeypatch): 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=set' 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()) diff --git a/tests/e2e/test_tui_webui_align.py b/tests/e2e/test_tui_webui_align.py index d862502f1..112cb55a1 100644 --- a/tests/e2e/test_tui_webui_align.py +++ b/tests/e2e/test_tui_webui_align.py @@ -202,7 +202,7 @@ def test_b1_list_hides_plaintext_key(self, isolated_home, work_dir, listed = run_slash('/model list', rt) assert 'acme' in listed.content assert 'sk-secret-plaintext' not in listed.content - assert 'key=set' in listed.content + assert 'key=sk-s****text' in listed.content settings = _settings(isolated_home) assert settings['providers']['acme']['api_key'] == 'sk-secret-plaintext' diff --git a/tests/e2e/test_tui_webui_usability.py b/tests/e2e/test_tui_webui_usability.py index 27986ddbc..069610b25 100644 --- a/tests/e2e/test_tui_webui_usability.py +++ b/tests/e2e/test_tui_webui_usability.py @@ -286,25 +286,26 @@ def test_partial_agent_settings_put_does_not_enable_memory( assert after['default_memory_enabled'] is False -class TestWebuiKeyMaskLeaksShortSecrets: - """TUI /model list shows key=set|missing. WebUI list must not leak a - short key via first4****last4. Presence is ``set``; long keys may 4+4. +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_is_not_invertible( + 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 masked in ('set', 'configured', '****', '') or masked == '••••' - # A 4+4 mask of a 12-char key is one character away from the secret. - assert not (masked.startswith(secret[:4]) and masked.endswith(secret[-4:])) + assert secret not in listed.content + assert masked == 'sk-t****acme' + assert 'key=sk-t****acme' in listed.content class TestResumeIndexVsMarker: diff --git a/webui/backend/app/backends/ms_agent/mapping.py b/webui/backend/app/backends/ms_agent/mapping.py index d618dbd4f..99f53a1fa 100644 --- a/webui/backend/app/backends/ms_agent/mapping.py +++ b/webui/backend/app/backends/ms_agent/mapping.py @@ -40,15 +40,10 @@ def _generation_defaults(protocol: str, provider: str) -> dict: def _mask(api_key: str) -> str: - """List-safe key presence. Short secrets must not leak a 4+4 prefix/suffix. - - Empty → not configured. Under 16 chars → ``set`` (same granularity as TUI - /model list). Longer keys may show first4****last4. - """ if not api_key: return "" - if len(api_key) < 16: - return "set" + if len(api_key) <= 8: + return "****" return f"{api_key[:4]}****{api_key[-4:]}" diff --git a/webui/backend/tests/test_mapping.py b/webui/backend/tests/test_mapping.py index 65137afaf..6b2bb85b1 100644 --- a/webui/backend/tests/test_mapping.py +++ b/webui/backend/tests/test_mapping.py @@ -70,6 +70,5 @@ def test_protocol_and_mask(): assert _protocol("openai_compat") == "openai" assert _protocol("anthropic_messages") == "anthropic" assert _mask("") == "" - assert _mask("short") == "set" - assert _mask("sk-test-acme") == "set" # 12 chars: no 4+4 leak + assert _mask("short") == "****" assert _mask("sk-secret-1234567890") == "sk-s****7890" 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'" }, From 679b90e9e6b3d5fc6dae5702d4a393808712a747 Mon Sep 17 00:00:00 2001 From: suluyan Date: Tue, 20 Oct 2026 16:28:38 +0800 Subject: [PATCH 21/21] style: format the model command module for lint Co-authored-by: Cursor --- ms_agent/command/builtin/config_cmds.py | 124 ++++++++++++------------ 1 file changed, 63 insertions(+), 61 deletions(-) diff --git a/ms_agent/command/builtin/config_cmds.py b/ms_agent/command/builtin/config_cmds.py index 4e8530bad..0437ef753 100644 --- a/ms_agent/command/builtin/config_cmds.py +++ b/ms_agent/command/builtin/config_cmds.py @@ -47,8 +47,7 @@ def _model_usage() -> str: ' 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' - ) + 'Example: /model dashscope qwen3.8-flash') _CLEAR = frozenset({'clear', '-', 'none'}) @@ -201,7 +200,8 @@ def _repair_runtime_model(config, llm=None): 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 '' + 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: @@ -275,7 +275,8 @@ def _current_model_text(service, model, llm=None) -> str: try: override = _mgr().list_custom_providers().get(raw_service) or {} lines.append( - f'Key: {_mask_key(_effective_api_key(raw_service, override))}') + f'Key: {_mask_key(_effective_api_key(raw_service, override))}' + ) except Exception: pass live_p, live_m, live_url = _live_identity(llm) @@ -290,8 +291,7 @@ def _current_model_text(service, model, llm=None) -> str: '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}.' - ) + f'realign with {realign}.') return '\n'.join(lines) + '\n' @@ -299,14 +299,12 @@ 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()) + 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(f'Cannot use this model: no usable API key for provider ' + f'{target_service}.') lines.append('Already on:') else: lines.append('Cannot switch to:') @@ -315,13 +313,11 @@ def _switch_fail_text(target_service, target_model, stay_service, 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} ') + 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} ') + 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( @@ -390,7 +386,8 @@ def _runtime_api_key(config, pid: str) -> str: if spec is None or getattr(config, 'llm', None) is None: return '' try: - return str(CredentialResolver.resolve_api_key(spec, config) or '').strip() + return str(CredentialResolver.resolve_api_key(spec, config) + or '').strip() except Exception: return '' @@ -400,6 +397,7 @@ def _effective_base_url(pid: str, override: dict) -> str: 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) @@ -419,10 +417,8 @@ def _effective_protocol(pid: str, provider: dict, override: dict) -> str: 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, - ) + 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)' @@ -437,17 +433,15 @@ def _live_fetch_note(pid: str, override: dict, provider: dict): return kept, dropped, '' -def _provider_status_lines(mgr, *, live: bool = False, +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, - ) + from ms_agent.llm.model_discovery import (format_id_list, + format_live_model_lines) if live: - lines = [ - 'Providers (live /models; preview only):' - ] + lines = ['Providers (live /models; preview only):'] else: lines = ['Providers:'] default = mgr.get_default_model() @@ -459,19 +453,23 @@ def _provider_status_lines(mgr, *, live: bool = False, 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')) + 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) + 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)"}') + 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.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)' @@ -499,13 +497,13 @@ 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.') + return (f' Switch with /model {provider_id} to use this ' + 'provider live.') TuiApp._apply_provider_credentials(config, overwrite=True) return '' @@ -721,23 +719,25 @@ def _cmd_model_switch(ctx: CommandContext, arg: str) -> CommandResult: return arg_error( '/model /', reason=f'No provider named {unknown}', - note=f'Known: {known}' if known else - 'Add one with /model provider add ', + 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 + 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) + new_model = _strip_provider_prefix(service_override or old_service, + new_model) if contains_secret(new_model): return _secret_model_error(ctx) @@ -759,12 +759,13 @@ def _revert_switch() -> None: 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)): + 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), + content=_switch_fail_text(service_override, new_model, old_service, + old_model), ) from ms_agent.llm.model_discovery import nearby_model_ids @@ -775,9 +776,10 @@ def _revert_switch() -> None: 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')}), + {'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() @@ -810,7 +812,8 @@ def _revert_switch() -> None: ) -def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult: +def _cmd_model_provider(ctx: CommandContext, + tokens: list[str]) -> CommandResult: mgr = _mgr() if not tokens: return CommandResult( @@ -842,8 +845,8 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult 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.') + 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: @@ -870,8 +873,8 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult 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}.') + return _finish_credential_write(ctx, pid, before, touched, + f'Updated provider {pid}.') if action == 'key': if len(rest) < 2: return arg_error( @@ -888,8 +891,8 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult ) 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}.') + 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( @@ -906,8 +909,8 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult ) 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}.') + return _finish_credential_write(ctx, pid, before, {'base_url'}, + f'Saved base URL for {pid}.') if action == 'remove': if not rest: return arg_error( @@ -919,10 +922,9 @@ def _cmd_model_provider(ctx: CommandContext, tokens: list[str]) -> CommandResult 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'), + content=(f'Cannot remove builtin provider {pid}. ' + 'Clear its override with /model provider key ' + f'{pid} clear'), ) mgr.remove_provider(pid) extra = ''