From 9af9dad122f6950ca6e8c6dfbce73cafba863528 Mon Sep 17 00:00:00 2001 From: suluyan Date: Wed, 16 Sep 2026 17:27:44 +0800 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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