已合并
[refactor] 全局配置目录与工作区状态分离 #228
[refactor] 全局配置目录与工作区状态分离 #228
已合并
fanglanyue创建于 8月22日
共 44 个文件变更+1749-538
@@ -34,7 +34,7 @@ Ascend生态下利用Agent调试调优存在几个典型难点:
34| -- | -- |34| -- | -- |
35| 统一入口 | 通过 `msagent` CLI 暴露统一入口,降低学习和切换成本。 |35| 统一入口 | 通过 `msagent` CLI 暴露统一入口,降低学习和切换成本。 |
36| 领域分治 | 用 Agent + SubAgent + Skill 的组合承载不同领域知识,而不是把所有逻辑塞进一个 Prompt。 |36| 领域分治 | 用 Agent + SubAgent + Skill 的组合承载不同领域知识,而不是把所有逻辑塞进一个 Prompt。 |
37-| 配置驱动 | LLM、Agent、Checkpointer、MCP、Sandbox、Approval 均通过 `.msagent/` 本地配置驱动。 |37+| 配置驱动 | LLM、Agent、Checkpointer、MCP、Sandbox、Approval 由安装包默认值和全局用户覆盖共同驱动。 |
38| 可控扩展 | Tool Pattern、Skill Pattern、MCP include/exclude 共同限定能力边界。 |38| 可控扩展 | Tool Pattern、Skill Pattern、MCP include/exclude 共同限定能力边界。 |
39| 稳定运行 | 检查点、重试、超时、审批、中间件和上下文压缩保证长链路对话可持续。 |39| 稳定运行 | 检查点、重试、超时、审批、中间件和上下文压缩保证长链路对话可持续。 |
40 40 
@@ -43,7 +43,7 @@ Ascend生态下利用Agent调试调优存在几个典型难点:
43#### 4.1 设计目标43#### 4.1 设计目标
44 44 
45- 支持多专业 Agent 并保持统一运行时模型。45- 支持多专业 Agent 并保持统一运行时模型。
46-- 支持本地配置、默认模板、版本迁移和目录式扩展。46+- 支持全局配置、只读默认模板、项目状态隔离和目录式扩展。
47- 支持 CLI/TUI 交互使用统一的图运行时。47- 支持 CLI/TUI 交互使用统一的图运行时。
48- 支持 Tool、Skill、MCP 的可组合装配与精细过滤。48- 支持 Tool、Skill、MCP 的可组合装配与精细过滤。
49- 支持长会话上下文治理,包括检查点、记忆注入、压缩卸载与大结果外置。49- 支持长会话上下文治理,包括检查点、记忆注入、压缩卸载与大结果外置。
@@ -75,7 +75,7 @@ flowchart TB
75 75 
76 CLI --> INIT["Initializer<br/>运行时装配与缓存中心"]76 CLI --> INIT["Initializer<br/>运行时装配与缓存中心"]
77 77 
78- INIT --> REG["ConfigRegistry<br/>.msagent 配置加载/迁移"]78+ INIT --> REG["ConfigRegistry<br/>内置默认 + 全局覆盖"]
79 INIT --> AF["AgentFactory<br/>deepagents 图构建"]79 INIT --> AF["AgentFactory<br/>deepagents 图构建"]
80 INIT --> MF["MCPFactory / MCPClient"]80 INIT --> MF["MCPFactory / MCPClient"]
81 INIT --> SF["SkillFactory"]81 INIT --> SF["SkillFactory"]
@@ -87,11 +87,11 @@ flowchart TB
87 DAG --> TOOLS["运行时 Tools<br/>deepagents 内置 + catalog tools + web_search + MCP tools"]87 DAG --> TOOLS["运行时 Tools<br/>deepagents 内置 + catalog tools + web_search + MCP tools"]
88 DAG --> CTX["AgentContext / AgentState"]88 DAG --> CTX["AgentContext / AgentState"]
89 89 
90- REG --> LOCAL["工作目录 .msagent/"]90+ REG --> GLOBAL["MSAGENT_HOME<br/>默认 ~/.msagent/"]
91 REG --> DEFAULTS["resources/configs/default/"]91 REG --> DEFAULTS["resources/configs/default/"]
92 92 
93 MF --> MCP["外部 MCP Servers<br/>默认 msprof-mcp"]93 MF --> MCP["外部 MCP Servers<br/>默认 msprof-mcp"]
94- SF --> SKILLS["项目 skills/ + 内置 skills + .msagent/skills"]94+ SF --> SKILLS["项目 skills/ + 全局 skills + 内置 skills"]
95 LF --> MODELS["OpenAI / Anthropic / Google(Gemini)"]95 LF --> MODELS["OpenAI / Anthropic / Google(Gemini)"]
96 CPF --> CHECKPOINT["Memory / SQLite Checkpointer"]96 CPF --> CHECKPOINT["Memory / SQLite Checkpointer"]
97 97 
@@ -105,7 +105,7 @@ flowchart TB
105整体上,`msAgent` 采用“**入口层统一、运行时集中装配、能力模块解耦**”的方案:105整体上,`msAgent` 采用“**入口层统一、运行时集中装配、能力模块解耦**”的方案:
106 106 
107- CLI/TUI 作为统一入口,所有交互最终都依赖 `Initializer.create_graph()` 组装同一类图运行时。107- CLI/TUI 作为统一入口,所有交互最终都依赖 `Initializer.create_graph()` 组装同一类图运行时。
108-- `ConfigRegistry` 负责把模板资源、工作目录配置和历史兼容迁移整理为强类型配置对象。108+- `ConfigRegistry` 负责合并安装包内置默认值与 `MSAGENT_HOME` 用户覆盖,并整理为强类型配置对象。
109- `AgentFactory` 负责把 LLM、工具、技能、中间件、后端和检查点真正拼成一个 `CompiledStateGraph`。109- `AgentFactory` 负责把 LLM、工具、技能、中间件、后端和检查点真正拼成一个 `CompiledStateGraph`。
110- `MessageDispatcher` 负责运行时阶段的消息流、流式渲染、审批恢复、自动压缩和 token 统计。110- `MessageDispatcher` 负责运行时阶段的消息流、流式渲染、审批恢复、自动压缩和 token 统计。
111- Tools / Skills / MCP 三种能力来源通过 Pattern 过滤共同决定每个 Agent 的“能力边界”。111- Tools / Skills / MCP 三种能力来源通过 Pattern 过滤共同决定每个 Agent 的“能力边界”。
@@ -146,7 +146,7 @@ sequenceDiagram
146 App->>Chat: 解析为默认会话命令146 App->>Chat: 解析为默认会话命令
147 Chat->>Ctx: 创建运行时 Context147 Chat->>Ctx: 创建运行时 Context
148 Ctx->>Init: load_agent_config / load_llm_config148 Ctx->>Init: load_agent_config / load_llm_config
149- Init->>Reg: 确保 .msagent 目录存在并加载配置149+ Init->>Reg: 解析全局路径、项目状态并加载分层配置
150 Reg-->>Init: Agent/LLM/MCP/Approval/Checkpointer 配置150 Reg-->>Init: Agent/LLM/MCP/Approval/Checkpointer 配置
151 Init->>MCP: 创建 MCPClient151 Init->>MCP: 创建 MCPClient
152 MCP-->>Init: 可见 MCP tools + module_map152 MCP-->>Init: 可见 MCP tools + module_map
@@ -163,8 +163,8 @@ sequenceDiagram
1631. **CLI 兼容路由**1631. **CLI 兼容路由**
164 `legacy.py` 将命令表面收敛为 `config` 与默认会话两类,同时保留 `--agent`、`--model`、`--approval-mode` 等运行参数。164 `legacy.py` 将命令表面收敛为 `config` 与默认会话两类,同时保留 `--agent`、`--model`、`--approval-mode` 等运行参数。
165 165 
166-2. **工作目录本地化配置**166+2. **配置与工作目录分离**
167- `ConfigRegistry.ensure_config_dir()` 在首次运行时把 `resources/configs/default/` 复制到 `<working-dir>/.msagent/`,并尽量将其加入 `.git/info/exclude`,保证配置本地化、避免误提交。167+ `AppPaths` 解析 `MSAGENT_HOME`,`ConfigRegistry` 直接读取 wheel 默认值并叠加全局覆盖;`working_dir` 仅保留为工具执行根目录。
168 168 
1693. **缓存式装配**1693. **缓存式装配**
170 `Initializer` 在 graph 构建后缓存:170 `Initializer` 在 graph 构建后缓存:
@@ -276,7 +276,7 @@ flowchart LR
276 276 
277#### 6.1 配置源与优先级277#### 6.1 配置源与优先级
278 278 
279-`msAgent` 的配置采用“安装包默认模板 + 工作目录本地实例”的组织方式。首次运行时,`ConfigRegistry.ensure_config_dir()` 会将安装包中的默认模板复制到当前工作目录下的 `.msagent/`,后续运行优先读取该本地目录。279+`msAgent` 的配置采用“安装包只读默认值 + 全局用户覆盖”的组织方式。`ConfigRegistry` 不再复制整套默认模板,项目 memory、checkpoint 和历史等运行状态统一写入全局 home 下的项目隔离目录。
280 280 
281典型结构如下:281典型结构如下:
282 282 
@@ -301,49 +301,52 @@ resources/configs/default/
301├─ prompts/301├─ prompts/
302└─ skills/302└─ skills/
303 303 
304-<working-dir>/.msagent/304+~/.msagent/
305-├─ config.llms.yml305+├─ config/
306-├─ config.mcp.json306+│ ├─ config.llms.yml
307-├─ config.approval.json307+│ ├─ config.mcp.json
308-├─ config.checkpoints.db308+│ ├─ config.approval.json
309-├─ memory.md309+│ ├─ agents/
310-├─ .history310+│ ├─ subagents/
311-├─ agents/311+│ ├─ llms/
312-├─ subagents/312+│ ├─ checkpointers/
313-├─ llms/313+│ └─ sandboxes/
314-├─ checkpointers/314+├─ prompts/
315-├─ sandboxes/
316├─ skills/315├─ skills/
317-├─ logs/
318├─ cache/316├─ cache/
319├─ oauth/317├─ oauth/
320-└─ conversation_history/318+├─ logs/
319+└─ state/projects/<project-id>/
320+ ├─ project.json
321+ ├─ checkpoints.sqlite
322+ ├─ memory.md
323+ ├─ history
324+ ├─ conversation_history/
325+ └─ audit_log/
321```326```
322 327 
323配置目录分为两层:328配置目录分为两层:
324 329 
325-- `resources/configs/default/`:随源码或安装包分发的**默认模板层**,定义开箱即用的 Agent、Prompt、Skill、MCP 与检查点模板。330+- `resources/configs/default/`:随源码或安装包分发的**只读默认层**。
326-- `<working-dir>/.msagent/`:针对当前工程实例化的**项目本地配置层**,既保存用户可编辑配置,也保存运行时状态与副产物。331+- `~/.msagent/config/`:所有项目共享的**用户覆盖层**。
332+- `~/.msagent/state/projects/<project-id>/`:按 canonical working directory 隔离的**项目状态层**。
327 333 
328配置读取与初始化规则如下:334配置读取与初始化规则如下:
329 335 
330| 场景 | 行为 |336| 场景 | 行为 |
331| -- | -- |337| -- | -- |
332-| 首次进入某个工作目录运行 `msagent` | 若 `.msagent/` 不存在,则从 `resources/configs/default/` 复制模板。 |338+| 首次进入某个工作目录运行 `msagent` | 创建全局目录和当前项目的 state 目录,不修改工作目录。 |
333-| 版本升级后再次运行 | 若模板新增文件,本地缺失则补齐;部分历史默认项会被温和迁移,但不会粗暴覆盖用户自定义。 |339+| 读取 LLM/Agent/SubAgent/Checkpointer/Sandbox | 按语义键将 `~/.msagent/config/` 中的用户项覆盖到内置默认项。 |
334-| 读取 LLM 配置 | 组合读取 `.msagent/config.llms.yml` 与 `.msagent/llms/*.yml`。 |340+| 切换 Agent 或 Model | 将当前选择写入项目 `project.json`,不修改或复制内置 Agent 定义。 |
335-| 读取 Agent 配置 | 优先读取 `.msagent/config.agents.yml`(若存在),并结合 `.msagent/agents/*.yml`。 |341+| 读取 MCP | 按 server name 合并内置定义和 `config/config.mcp.json`。 |
336-| 读取 SubAgent 配置 | 优先读取 `.msagent/config.subagents.yml`(若存在),并结合 `.msagent/subagents/*.yml`。 |342+| 读取审批配置 | 用户文件存在时读取 `config/config.approval.json`,否则直接读取内置默认。 |
337-| 读取 Checkpointer 配置 | 优先读取 `.msagent/config.checkpointers.yml`(若存在),并结合 `.msagent/checkpointers/*.yml`。 |343+| 高级修改内置 Agent | 仅写入名称、版本及被修改字段;其余字段继续继承安装包默认定义。 |
338-| 读取 Sandbox 配置 | 读取 `.msagent/sandboxes/*.yml`。 |
339-| 读取 MCP 配置 | 读取 `.msagent/config.mcp.json`。 |
340-| 读取审批配置 | 读取 `.msagent/config.approval.json`。 |
341 344 
342配置系统的关键特性如下:345配置系统的关键特性如下:
343 346 
344-- **默认模板复制**:把“安装态默认值”和“项目本地配置”分层,避免用户直接改包内资源。347+- **默认值不复制**:wheel 更新可以直接提供新默认项,用户目录只保存实际覆盖。
345- **目录式扩展**:支持 `agents/*.yml`、`llms/*.yml`、`checkpointers/*.yml`、`sandboxes/*.yml` 等目录化组织方式,便于逐个维护。348- **目录式扩展**:支持 `agents/*.yml`、`llms/*.yml`、`checkpointers/*.yml`、`sandboxes/*.yml` 等目录化组织方式,便于逐个维护。
346-- **温和迁移**:`ConfigRegistry` 与 `AgentConfig.migrate()` 共同负责缺失文件补齐和旧版字段平滑迁移,例如 `tools`、`skills`、`compression`、`retry` 的历史格式兼容。349+- **显式路径注入**:`AppPaths`、`ProjectPaths` 统一控制配置、全局资源和项目状态落点。
347- **强类型校验**:最终都会收敛为 Pydantic 配置对象,保证 Provider、Pattern、超时、审批规则、MCP transport 等关键字段可校验。350- **强类型校验**:最终都会收敛为 Pydantic 配置对象,保证 Provider、Pattern、超时、审批规则、MCP transport 等关键字段可校验。
348 351 
349#### 6.2 关键配置对象352#### 6.2 关键配置对象
@@ -358,22 +361,13 @@ resources/configs/default/
358| `ToolApprovalConfig` | `interrupt_on`、`decision_rules` | 将高风险工具纳入审批与自动决策框架。 |361| `ToolApprovalConfig` | `interrupt_on`、`decision_rules` | 将高风险工具纳入审批与自动决策框架。 |
359| `CheckpointerConfig` | `type`、`connection_string` | 决定会话状态持久化后端。 |362| `CheckpointerConfig` | `type`、`connection_string` | 决定会话状态持久化后端。 |
360 363 
361-#### 6.3 本地目录布局364+#### 6.3 路径职责
362 365 
363-工作目录 `.msagent/` 既是运行时配置根目录,也是会话副产物落地点。典型内容包括:366+- `working_dir`:Agent 文件操作、shell 和项目级 `skills/` 的根目录。
367+- `AppPaths`:全局配置、Prompt、Skill、cache、OAuth 和日志目录。
368+- `ProjectPaths`:当前项目的 memory、history、checkpoint、conversation history 和 audit。
364 369 
365-- `config.llms.yml`370+不同项目共享用户配置,但不会共享会话状态。
366-- `agents/*.yml`
367-- `subagents/*.yml`
368-- `checkpointers/*.yml`
369-- `config.mcp.json`
370-- `config.approval.json`
371-- `memory.md`
372-- `logs/`
373-- `conversation_history/`
374-- `config.checkpoints.db`
375- 
376-这种布局让 `msAgent` 天然适合作为“项目级 Agent 工具链”,不同工程目录可以有自己的 Agent/MCP/Skill 组合,而不是共用一份全局状态。
377 371 
378### 7. Tool / Skill / MCP 能力面设计372### 7. Tool / Skill / MCP 能力面设计
379 373 
@@ -396,12 +390,12 @@ resources/configs/default/
396Skill 是面向工作流复用的轻量资产,当前扫描顺序为:390Skill 是面向工作流复用的轻量资产,当前扫描顺序为:
397 391 
3981. `<working-dir>/skills`3921. `<working-dir>/skills`
399-2. 仓库根目录或打包后的内置 `skills/`393+2. `~/.msagent/skills`
400-3. `<working-dir>/.msagent/skills`394+3. 仓库根目录或打包后的内置 `skills/`
401 395 
402这意味着:396这意味着:
403 397 
404-- 项目级 Skill 可以覆盖默认 Skill。398+- 项目级和全局用户 Skill 可以覆盖默认 Skill。
405- 打包态和源码态共用统一的内置 Skill 分发方案。399- 打包态和源码态共用统一的内置 Skill 分发方案。
406- Skill 通过 `SKILL.md` 描述触发语义与执行要求,而不是强绑定到 Python 代码入口。400- Skill 通过 `SKILL.md` 描述触发语义与执行要求,而不是强绑定到 Python 代码入口。
407 401 
@@ -600,7 +594,7 @@ CLI 层由 `Session + InteractivePrompt + Renderer + Dispatcher/Handler` 组成
600 594 
601#### 1.1 初始化与查看配置595#### 1.1 初始化与查看配置
602 596 
603-首次在项目目录运行 `msagent` 时,会自动生成 `.msagent/` 本地配置目录。可通过以下命令查看当前配置:597+首次运行时会在 `MSAGENT_HOME`(默认 `~/.msagent/`)创建全局目录和当前项目状态目录,不会修改项目工作目录。可通过以下命令查看当前配置:
604 598 
605```bash599```bash
606msagent config --show600msagent config --show
@@ -645,7 +639,7 @@ msagent --approval-mode active
645 639 
646#### 2.1 LLM 配置640#### 2.1 LLM 配置
647 641 
648-默认模板中的 `config.llms.yml` 定义了主模型入口,额外模型别名可以放入 `.msagent/llms/*.yml`。当前 schema 支持:642+默认模板中的 `config.llms.yml` 定义了主模型入口,额外模型别名可以放入 `~/.msagent/config/llms/*.yml`。当前 schema 支持:
649 643 
650- `openai`644- `openai`
651- `anthropic`645- `anthropic`
@@ -690,7 +684,7 @@ skills:
690 684 
691#### 2.3 MCP 配置685#### 2.3 MCP 配置
692 686 
693-MCP 配置位于 `.msagent/config.mcp.json`。默认服务如下:687+MCP 用户覆盖配置位于 `~/.msagent/config/config.mcp.json`。内置服务定义来自安装包资源,例如本地 stdio 服务可写为:
694 688 
695```json689```json
696{690{
@@ -716,13 +710,13 @@ MCP 配置位于 `.msagent/config.mcp.json`。默认服务如下:
716推荐优先放在以下位置之一:710推荐优先放在以下位置之一:
717 711 
718- `<working-dir>/skills/<category>/<skill-name>/SKILL.md`712- `<working-dir>/skills/<category>/<skill-name>/SKILL.md`
719-- `<working-dir>/.msagent/skills/<category>/<skill-name>/SKILL.md`713+- `~/.msagent/skills/<category>/<skill-name>/SKILL.md`
720 714 
721同时需要在目标 Agent 配置中放开对应 `skills.patterns`。715同时需要在目标 Agent 配置中放开对应 `skills.patterns`。
722 716 
723#### 3.2 新增 Agent717#### 3.2 新增 Agent
724 718 
725-可在 `.msagent/agents/` 下新增 YAML 文件,定义:719+可在 `~/.msagent/config/agents/` 下新增 YAML 文件,定义:
726 720 
727- `name`721- `name`
728- `prompt`722- `prompt`
@@ -768,11 +762,12 @@ MCP 配置位于 `.msagent/config.mcp.json`。默认服务如下:
768 762 
769#### 3.1 配置系统测试763#### 3.1 配置系统测试
770 764 
771-**目标**:保证 `.msagent` 模板复制、配置装载、目录式扩展、版本迁移和兼容行为稳定。765+**目标**:保证全局路径解析、内置值与用户覆盖装载、项目状态隔离和目录式扩展行为稳定。
772 766 
773覆盖的核心用例如下:767覆盖的核心用例如下:
774 768 
775-- 首次运行时自动生成 `.msagent/`,并能从模板目录复制缺失文件。769+- 首次运行时只在 `MSAGENT_HOME` 创建目录,工作目录不生成 `.msagent/`。
770+- 相同项目路径的 project id 稳定,同名但路径不同的项目状态互相隔离。
776- `config.llms.yml` 与 `llms/*.yml` 混合加载时,别名不重复且 Provider 正常归一化。771- `config.llms.yml` 与 `llms/*.yml` 混合加载时,别名不重复且 Provider 正常归一化。
777- Agent/SubAgent/Checkpointer/Sandbox 配置能正确解析引用关系。772- Agent/SubAgent/Checkpointer/Sandbox 配置能正确解析引用关系。
778- 历史字段迁移正常,例如:773- 历史字段迁移正常,例如:
@@ -510,7 +510,7 @@ uv run msprof-mcp
510 510 
511msprof-mcp 通过 stdio 传输协议与 MCP Host 通信,典型集成场景包括:511msprof-mcp 通过 stdio 传输协议与 MCP Host 通信,典型集成场景包括:
512 512 
513-- msAgent Profiler Agent:通过 `.msagent/config.mcp.json` 配置,Profiler 默认启用 `mcp:msprof-mcp:*` Tool Pattern。513+- msAgent Profiler Agent:通过 `~/.msagent/config/config.mcp.json` 覆盖 MCP 配置,Profiler 可通过 `mcp:msprof-mcp:*` Tool Pattern 启用对应工具。
514- Cherry Studio / Claude Desktop:通过 MCP 配置 JSON 添加服务。514- Cherry Studio / Claude Desktop:通过 MCP 配置 JSON 添加服务。
515- 其他 MCP 客户端:只要支持 stdio 传输即可接入。515- 其他 MCP 客户端:只要支持 stdio 传输即可接入。
516 516 
@@ -9,7 +9,7 @@ msAgent 中的 Agent 采用声明式配置定义,无需编写 Python 代码。
9- **YAML 配置文件**:定义 Agent 的名称、模型、工具、Skill、子 Agent 等9- **YAML 配置文件**:定义 Agent 的名称、模型、工具、Skill、子 Agent 等
10- **Prompt 文件**:定义 Agent 的系统提示词,决定其行为和能力10- **Prompt 文件**:定义 Agent 的系统提示词,决定其行为和能力
11 11 
12-编辑 `resources/configs/default/` 下的文件后,首次运行时会自动拷贝到用户的 `.msagent/` 目录。12+`resources/configs/default/` 中的定义会直接作为只读内置默认值加载,不会在首次运行时整目录复制。用户覆盖项放在 `~/.msagent/config/`,用户 Prompt 放在 `~/.msagent/prompts/`。
13 13 
14## 2. 目录结构14## 2. 目录结构
15 15 
@@ -77,11 +77,11 @@ retry:
77| `version` | 是 | 配置版本,使用 `__APP_VERSION__` 占位符,运行时会自动替换 |77| `version` | 是 | 配置版本,使用 `__APP_VERSION__` 占位符,运行时会自动替换 |
78| `name` | 是 | Agent 唯一标识,**必须与文件名一致**(如 `MyAgent.yml` 对应 `name: MyAgent`) |78| `name` | 是 | Agent 唯一标识,**必须与文件名一致**(如 `MyAgent.yml` 对应 `name: MyAgent`) |
79| `description` | 是 | Agent 功能简述,展示在 `/agents` 列表中 |79| `description` | 是 | Agent 功能简述,展示在 `/agents` 列表中 |
80-| `prompt` | 是 | Prompt 文件路径列表(相对于 `.msagent/`),按顺序拼接 |80+| `prompt` | 是 | Prompt 文件路径列表;内置配置相对于默认资源目录,用户覆盖配置相对于 `~/.msagent/` |
81| `llm` | 是 | 使用的 LLM 别名,`default` 对应默认模型,也可指定 `sonnet`、`opus` 等 |81| `llm` | 是 | 使用的 LLM 别名,`default` 对应默认模型,也可指定 `sonnet`、`opus` 等 |
82| `checkpointer` | 是 | 会话持久化后端,通常为 `sqlite` |82| `checkpointer` | 是 | 会话持久化后端,通常为 `sqlite` |
83| `default` | 是 | 是否为默认 Agent,**整个系统中必须恰好有一个 `default: true`** |83| `default` | 是 | 是否为默认 Agent,**整个系统中必须恰好有一个 `default: true`** |
84-| `subagents` | 否 | 引用的子 Agent 名称列表,需在 `.msagent/subagents/` 中定义 |84+| `subagents` | 否 | 引用的子 Agent 名称列表,可来自内置配置或 `~/.msagent/config/subagents/` |
85| `tools.patterns` | 是 | 工具过滤规则,格式 `类别:模块:名称`,支持 `*` 通配符,`!` 前缀表示排除 |85| `tools.patterns` | 是 | 工具过滤规则,格式 `类别:模块:名称`,支持 `*` 通配符,`!` 前缀表示排除 |
86| `skills.patterns` | 是 | Skill 过滤规则,格式 `类别:名称`,完整列表见 [Skills](../../../skills/README.md) |86| `skills.patterns` | 是 | Skill 过滤规则,格式 `类别:名称`,完整列表见 [Skills](../../../skills/README.md) |
87 87 
@@ -126,7 +126,7 @@ MindStudio-Agent 采用模块化架构设计,基于 deepagents 运行时与 La
126#### 4. Skills 模块126#### 4. Skills 模块
127 127 
128- **职责**:128- **职责**:
129- - SkillFactory:从仓库内 `skills/` 与工作目录 `.msagent/skills/` 加载技能元数据129+ - SkillFactory:从工作目录 `skills/`、全局 `~/.msagent/skills/` 与内置目录加载技能元数据
130 - 技能筛选:根据 Agent 配置中的 patterns 进行技能注入130 - 技能筛选:根据 Agent 配置中的 patterns 进行技能注入
131 - 技能目录:为 CLI 与运行时提供技能发现能力131 - 技能目录:为 CLI 与运行时提供技能发现能力
132 - 分发能力:构建产物可将仓库 `skills/` 打包到默认资源目录,便于安装版与外部 IDE / Agent 复用132 - 分发能力:构建产物可将仓库 `skills/` 打包到默认资源目录,便于安装版与外部 IDE / Agent 复用
@@ -81,7 +81,7 @@ uv run msagent --version
81### 建议阅读顺序81### 建议阅读顺序
82 82 
831. [架构概览](arch_overview.md):了解 CLI、Agent Factory、Tools、Skills、MCP 与中间件的分层关系831. [架构概览](arch_overview.md):了解 CLI、Agent Factory、Tools、Skills、MCP 与中间件的分层关系
84-2. [配置与扩展](../user_guide/configuration-and-extension.md):理解 `.msagent/` 本地配置、MCP 与 Skills 加载顺序84+2. [配置与扩展](../user_guide/configuration-and-extension.md):理解全局配置、项目状态、MCP 与 Skills 加载顺序
853. [Agent / Tool / Skill 过滤规则](../user_guide/agent-tool-skill-filter-rules.md):修改 Agent 能力边界前必读853. [Agent / Tool / Skill 过滤规则](../user_guide/agent-tool-skill-filter-rules.md):修改 Agent 能力边界前必读
864. 与你改动相关的 Agent 指南或用户指南864. 与你改动相关的 Agent 指南或用户指南
87 87 
@@ -182,7 +182,7 @@ Skill 匹配与过滤规则见 [Agent / Tool / Skill 过滤规则](../user_guide
182 182 
183### Agent / 配置贡献183### Agent / 配置贡献
184 184 
185-Agent 定义位于 `resources/configs/default/agents/`,运行时复制到工作目录 `.msagent/agents/`。修改 Tool 或 Skill 可见范围时:185+内置 Agent 定义位于 `resources/configs/default/agents/`,运行时直接读取;用户覆盖位于 `~/.msagent/config/agents/`。修改 Tool 或 Skill 可见范围时:
186 186 
187- `tools.patterns` 使用 `<category>:<module>:<name_pattern>` 三段式187- `tools.patterns` 使用 `<category>:<module>:<name_pattern>` 三段式
188- `skills.patterns` 使用 `<category>:<name_pattern>` 两段式188- `skills.patterns` 使用 `<category>:<name_pattern>` 两段式
@@ -98,23 +98,20 @@ msagent --help
98 98 
99## 5. 升级与卸载99## 5. 升级与卸载
100 100 
101-`msagent` 会在当前工作目录下生成 `.msagent/` 本地目录,用于保存缓存、会话历史、日志和运行时配置等内容。101+`msagent` 将用户配置和运行状态保存在 `MSAGENT_HOME`(默认 `~/.msagent/`),不会在工作目录生成新的 `.msagent/`。
102 102 
103-- 升级前,先删除当前工作目录下的 `.msagent/` 文件夹,避免旧缓存影响新版本行为。103+当前阶段尚未提供旧项目 `.msagent` 的自动迁移。升级或切换版本时请保留旧目录,不要将删除配置目录作为升级步骤。
104-- 卸载时,如果后续不再使用 `msagent`,也建议一并删除 `.msagent/` 文件夹。
105 104 
106常见操作示例:105常见操作示例:
107 106 
108- 升级107- 升级
109 108 
110 ```shell109 ```shell
111- rm -rf .msagent110+ pip install -U mindstudio-agent
112- pip install mindstudio-agent
113 ```111 ```
114 112 
115- 卸载113- 卸载
116 114 
117 ```shell115 ```shell
118- rm -rf .msagent
119 pip uninstall mindstudio-agent116 pip uninstall mindstudio-agent
120 ```117 ```
@@ -113,7 +113,7 @@ Skill 是面向特定场景的专项能力模块(如性能分析、模型量
113 113 
114### 4.5 保存长期记忆114### 4.5 保存长期记忆
115 115 
116-如果某些信息需要在后续会话中持续生效,可以用 `/remember` 保存为当前项目的长期记忆。记忆会写入 `.msagent/memory.md`,后续会话会自动读取。116+如果某些信息需要在后续会话中持续生效,可以用 `/remember` 保存为当前项目的长期记忆。记忆会写入 `~/.msagent/state/projects/<project-id>/memory.md`,后续会话会自动读取。
117 117 
118| 命令 | 说明 |118| 命令 | 说明 |
119| --- | --- |119| --- | --- |
@@ -1,16 +1,18 @@
1# 配置与扩展1# 配置与扩展
2 2 
3-本文档汇总 `msAgent` 当前代码实现中的项目本地配置、MCP 扩展和 Skills 扩展方式。3+本文档汇总 `msAgent` 当前代码实现中的全局配置、项目状态、MCP 扩展和 Skills 扩展方式。
4 4 
5-## 项目本地配置目录5+## 全局配置目录
6 6 
7-`msAgent` 使用项目本地配置。首次在某个工作目录运行时,会自动把 `resources/configs/default/` 中的默认模板复制到:7+`msAgent` 将用户配置统一保存在 `MSAGENT_HOME`。未设置该环境变量时,默认目录为:
8 8 
9```text9```text
10-<working-dir>/.msagent/10+~/.msagent/
11```11```
12 12 
13-如果当前工作目录是 Git 仓库,运行时还会尝试把 `.msagent/` 加入 `.git/info/exclude`,避免本地配置被误提交。13+工作目录只作为 Agent 和工具的执行根目录,不再生成项目内 `.msagent/`。安装包中的默认配置直接从 wheel 读取;全局目录只保存用户覆盖项。
14+ 
15+现阶段不会自动导入已有的 `<working-dir>/.msagent/`,请先保留旧目录,等待后续迁移功能。
14 16 
15## 默认模板与运行时文件17## 默认模板与运行时文件
16 18 
@@ -18,58 +20,63 @@
18 20 
19| 路径 | 说明 |21| 路径 | 说明 |
20|---|---|22|---|---|
21-| `.msagent/config.llms.yml` | 默认模型配置入口文件。 |23+| `~/.msagent/config/config.llms.yml` | 默认模型覆盖配置。 |
22-| `.msagent/llms/*.yml` | 额外的模型别名配置。 |24+| `~/.msagent/config/llms/*.yml` | 额外的模型别名覆盖配置。 |
23-| `.msagent/agents/*.yml` | Agent 定义。当前默认包含 `Profiler.yml`、`Accuracy.yml`、`Quantizer.yml`、`Modeling.yml`、`Operator.yml`、`Minos.yml`。 |25+| `~/.msagent/config/agents/*.yml` | 高级扩展使用的 Agent 最小覆盖或新增定义;正常切换不会生成。 |
24-| `.msagent/subagents/*.yml` | SubAgent 定义。 |26+| `~/.msagent/config/subagents/*.yml` | 高级扩展使用的 SubAgent 最小覆盖或新增定义。 |
25-| `.msagent/checkpointers/*.yml` | Checkpointer 配置。当前默认包含 `memory.yml`、`sqlite.yml`。 |27+| `~/.msagent/config/checkpointers/*.yml` | Checkpointer 覆盖配置。 |
26-| `.msagent/sandboxes/*.yml` | Sandbox 配置模板。 |28+| `~/.msagent/config/sandboxes/*.yml` | Sandbox 覆盖配置。 |
27-| `.msagent/prompts/` | Agent / SubAgent 使用的 Prompt 模板。 |29+| `~/.msagent/prompts/` | 用户 Prompt 文件。 |
28-| `.msagent/skills/` | 随模板分发的内置 Skills。 |30+| `~/.msagent/skills/` | 用户安装的 Skills。 |
29-| `.msagent/config.mcp.json` | MCP 服务器配置。 |31+| `~/.msagent/config/config.mcp.json` | MCP 服务器覆盖配置。 |
30-| `.msagent/config.approval.json` | 工具审批规则配置。 |32+| `~/.msagent/config/config.approval.json` | 工具审批规则覆盖配置。 |
31-| `.msagent/README.md` | 本地配置目录说明。 |
32 33 
33运行时会按需生成以下文件或目录:34运行时会按需生成以下文件或目录:
34 35 
35| 路径 | 说明 |36| 路径 | 说明 |
36|---|---|37|---|---|
37-| `.msagent/config.checkpoints.db` | 会话 checkpoint 数据库。 |38+| `~/.msagent/state/projects/<project-id>/project.json` | 工作目录标识,以及该项目当前 Agent、各 Agent 的模型偏好。 |
38-| `.msagent/.history` | 输入历史。 |39+| `~/.msagent/state/projects/<project-id>/checkpoints.sqlite` | 当前项目的 checkpoint 数据库。 |
39-| `.msagent/memory.md` | 用户偏好和项目上下文记忆。 |40+| `~/.msagent/state/projects/<project-id>/history` | 当前项目的输入历史。 |
40-| `.msagent/cache/mcp/` | MCP 运行缓存。 |41+| `~/.msagent/state/projects/<project-id>/memory.md` | 当前项目的长期记忆。 |
41-| `.msagent/oauth/mcp/` | MCP OAuth 相关缓存。 |42+| `~/.msagent/state/projects/<project-id>/conversation_history/` | 当前项目的会话历史。 |
42-| `.msagent/logs/` | 本地日志目录。 |43+| `~/.msagent/state/projects/<project-id>/audit_log/` | 当前项目的审计日志。 |
43-| `.msagent/conversation_history/` | 会话历史目录。 |44+| `~/.msagent/cache/mcp/` | 全局 MCP 运行缓存。 |
45+| `~/.msagent/oauth/mcp/` | 全局 MCP OAuth 数据。 |
46+| `~/.msagent/logs/` | 全局日志目录。 |
44 47 
45## 项目长期记忆48## 项目长期记忆
46 49 
47-`.msagent/memory.md` 用于保存当前项目的长期记忆,典型内容包括用户偏好、项目背景、长期有效的路径说明和后续任务需要持续参考的事实。可以在交互式会话中使用以下命令维护:50+`~/.msagent/state/projects/<project-id>/memory.md` 用于保存当前项目的长期记忆。可以在交互式会话中使用以下命令维护:
48 51 
49| 命令 | 说明 |52| 命令 | 说明 |
50|---|---|53|---|---|
51-| `/remember <content>` | 追加一条长期记忆。首次使用时会自动创建 `.msagent/memory.md`。 |54+| `/remember <content>` | 追加一条长期记忆。首次使用时会自动创建项目 memory 文件。 |
52| `/showmemory` | 查看当前项目已保存的长期记忆。 |55| `/showmemory` | 查看当前项目已保存的长期记忆。 |
53 56 
54-后续会话会自动读取 `.msagent/memory.md` 中的有效内容,并作为 `<user-memory>` 上下文提供给 Agent。默认模板内容不会作为有效记忆注入。57+后续会话会自动读取对应项目的 memory 文件,并作为 `<user-memory>` 上下文提供给 Agent。默认模板内容不会作为有效记忆注入。
55 58 
56-长期记忆适合保存稳定信息,不建议写入 API Key、密码、令牌等敏感数据。需要删除或修改记忆时,可以直接编辑 `.msagent/memory.md`。59+长期记忆适合保存稳定信息,不建议写入 API Key、密码、令牌等敏感数据。
57 60 
58## 配置读取方式61## 配置读取方式
59 62 
60当前实现支持“单文件配置”和“目录配置”两种方式并存:63当前实现支持“单文件配置”和“目录配置”两种方式并存:
61 64 
62-- LLM:读取 `.msagent/config.llms.yml` 与 `.msagent/llms/*.yml`65+- LLM:内置默认值叠加 `~/.msagent/config/config.llms.yml` 与 `config/llms/*.yml`
63-- Agent:优先读取 `.msagent/config.agents.yml`(如果存在)以及 `.msagent/agents/*.yml`66+- Agent:内置默认值叠加 `~/.msagent/config/config.agents.yml` 与 `config/agents/*.yml`
64-- SubAgent:优先读取 `.msagent/config.subagents.yml`(如果存在)以及 `.msagent/subagents/*.yml`67+- SubAgent:内置默认值叠加 `~/.msagent/config/config.subagents.yml` 与 `config/subagents/*.yml`
65-- Checkpointer:优先读取 `.msagent/config.checkpointers.yml`(如果存在)以及 `.msagent/checkpointers/*.yml`68+- Checkpointer:内置默认值叠加 `~/.msagent/config/config.checkpointers.yml` 与 `config/checkpointers/*.yml`
66-- Sandbox:读取 `.msagent/sandboxes/*.yml`69+- Sandbox:内置默认值叠加 `~/.msagent/config/sandboxes/*.yml`
67 70 
68默认模板当前以目录式配置为主。71默认模板当前以目录式配置为主。
69 72 
73+内置 Agent 的 Prompt、Tools、Skills、SubAgent 等完整定义由安装包维护,普通用户无需复制或编辑。`/agents` 切换结果保存在当前项目的 `project.json`;`/models` 的选择也按 Agent 保存在同一项目状态中。启动时的选择优先级为:显式 `--agent`/`--model` 参数、当前项目偏好、安装包默认值。
74+ 
75+只有高级扩展或显式修改 Agent 字段时才会生成 `config/agents/*.yml`。生成文件采用字段级最小覆盖,未出现的字段继续继承安装包默认定义。
76+ 
70## MCP 配置77## MCP 配置
71 78 
72-默认模板会启用 `msprof-mcp`。当前默认配置等价于:79+内置 MCP 定义来自安装包中的 `resources/configs/default/config.mcp.json`,用户文件按 server name 覆盖内置定义。以下是一个本地 stdio 服务示例:
73 80 
74```json81```json
75{82{
@@ -118,21 +125,21 @@
118日常使用方式:125日常使用方式:
119 126 
120- 用 `/mcp` 在会话中切换已有 MCP 服务的启用状态127- 用 `/mcp` 在会话中切换已有 MCP 服务的启用状态
121-- 直接编辑 `.msagent/config.mcp.json` 来新增、删除或调整服务定义128+- 直接编辑 `~/.msagent/config/config.mcp.json` 来新增、删除或调整服务定义
122 129 
123## Skills 扩展130## Skills 扩展
124 131 
125当前 Skills 会按以下顺序扫描:132当前 Skills 会按以下顺序扫描:
126 133 
1271. `<working-dir>/skills`1341. `<working-dir>/skills`
128-2. 内置 Skills 目录:优先使用仓库根目录 `skills/`;已安装 wheel 时使用打包后的 `resources/configs/default/skills/`135+2. `~/.msagent/skills`
129-3. `<working-dir>/.msagent/skills`136+3. 内置 Skills 目录:优先使用仓库根目录 `skills/`;已安装 wheel 时使用打包后的资源
130 137 
131同名 Skill 按“先加载优先”处理,因此当前优先级是:138同名 Skill 按“先加载优先”处理,因此当前优先级是:
132 139 
1331. 项目根目录下的 `skills/`1401. 项目根目录下的 `skills/`
134-2. 内置 Skills141+2. 全局用户 Skills
135-3. `.msagent/skills/`142+3. 内置 Skills
136 143 
137## Skill 目录结构144## Skill 目录结构
138 145 
@@ -104,16 +104,16 @@ msagent
104 104 
105## 4. 压缩后,数据存在哪里105## 4. 压缩后,数据存在哪里
106 106 
107-压缩后的原始历史消息会写入当前工作目录下:107+压缩后的原始历史消息会写入当前项目的全局状态目录:
108 108 
109```text109```text
110-<working-dir>/.msagent/conversation_history/<thread_id>.md110+~/.msagent/state/projects/<project-id>/conversation_history/<thread_id>.md
111```111```
112 112 
113例如:113例如:
114 114 
115```text115```text
116-/path/to/project/.msagent/conversation_history/8d6c4f3f-....md116+~/.msagent/state/projects/my-project-a1b2c3d4e5f6/conversation_history/8d6c4f3f-....md
117```117```
118 118 
119这个文件是按时间追加写入的。每次压缩都会新增一个段落,类似:119这个文件是按时间追加写入的。每次压缩都会新增一个段落,类似:
@@ -141,7 +141,7 @@ Tool: ...
141它对应的实际磁盘目录就是上面的:141它对应的实际磁盘目录就是上面的:
142 142 
143```text143```text
144-<working-dir>/.msagent/conversation_history/<thread_id>.md144+~/.msagent/state/projects/<project-id>/conversation_history/<thread_id>.md
145```145```
146 146 
147## 5. 它具体是怎么工作的147## 5. 它具体是怎么工作的
@@ -224,7 +224,7 @@ compression:
224检查:224检查:
225 225 
226```text226```text
227-<working-dir>/.msagent/conversation_history/227+~/.msagent/state/projects/<project-id>/conversation_history/
228```228```
229 229 
230是否已经生成当前 `thread_id` 对应的 `.md` 文件。230是否已经生成当前 `thread_id` 对应的 `.md` 文件。
@@ -254,7 +254,7 @@ compression:
254 254 
255如果对话还不够长,压缩不会发生。255如果对话还不够长,压缩不会发生。
256 256 
257-### 4) 工作目录下是否能写入 `.msagent/conversation_history/`257+### 4) `MSAGENT_HOME` 下的项目状态目录是否可写
258 258 
259如果历史文件写入失败,摘要仍可能生成,但原始消息不会成功落盘。此时终端会出现 warning。259如果历史文件写入失败,摘要仍可能生成,但原始消息不会成功落盘。此时终端会出现 warning。
260 260 
@@ -284,4 +284,4 @@ compression:
284 284 
285## 11. 一句话总结285## 11. 一句话总结
286 286 
287-对使用者来说,这个特性基本是“开箱即用”的:正常长时间使用 `msagent`,当上下文接近上限时,它会自动把旧消息总结并卸载到 `.msagent/conversation_history/`,从而释放上下文窗口,同时保留可追溯的原始历史。287+对使用者来说,这个特性基本是“开箱即用”的:正常长时间使用 `msagent`,当上下文接近上限时,它会自动把旧消息总结并卸载到全局目录中的项目状态区,从而释放上下文窗口,同时保留可追溯的原始历史。
@@ -2,21 +2,23 @@
2 2 
3## 1. msagent 第一次启动时会生成哪些本地文件?3## 1. msagent 第一次启动时会生成哪些本地文件?
4 4 
5-`msagent` 使用项目本地配置。首次在某个工作目录启动时,会自动生成:5+`msagent` 使用全局配置目录。默认位置为:
6 6 
7```text7```text
8-.msagent/8+~/.msagent/
9```9```
10 10 
11+其中 `config/` 保存用户覆盖配置,`state/projects/<project-id>/` 保存各项目独立的 memory、history、checkpoint 和会话历史。工作目录不会生成新的 `.msagent/`。
12+ 
11这个目录里通常会包含:13这个目录里通常会包含:
12 14 
13- LLM 配置15- LLM 配置
14-- Agent / SubAgent 配置
15- MCP 配置16- MCP 配置
16-- Prompt 模板17+- 用户安装的 Skills
17-- Skills
18- 日志、缓存、会话历史和 checkpoint 数据18- 日志、缓存、会话历史和 checkpoint 数据
19 19 
20+内置 Agent / SubAgent 定义和 Prompt 默认由安装包直接提供,不会在首次启动时复制到这里。当前 workspace 选择的 Agent 和 Model 记录在对应项目的 `project.json` 中。
21+ 
20更完整的目录说明见 [配置与扩展](configuration-and-extension.md)。22更完整的目录说明见 [配置与扩展](configuration-and-extension.md)。
21 23 
22## 2. 如何打开或关闭 MCP 服务?24## 2. 如何打开或关闭 MCP 服务?
@@ -24,7 +26,7 @@
24有两种常见方式:26有两种常见方式:
25 27 
26- 在会话里通过 `/mcp` 查看和切换已配置的 MCP 服务28- 在会话里通过 `/mcp` 查看和切换已配置的 MCP 服务
27-- 直接编辑 `.msagent/config.mcp.json`29+- 直接编辑 `~/.msagent/config/config.mcp.json`
28 30 
29默认模板会启用 `msprof-mcp`。如果你要接入新的本地或远程 MCP 服务,建议先参考 [配置与扩展](configuration-and-extension.md) 里的字段说明。31默认模板会启用 `msprof-mcp`。如果你要接入新的本地或远程 MCP 服务,建议先参考 [配置与扩展](configuration-and-extension.md) 里的字段说明。
30 32 
@@ -59,10 +61,10 @@
59 61 
60## 4. 运行日志在哪里看?62## 4. 运行日志在哪里看?
61 63 
62-启用后,日志会写到当前工作目录下:64+日志统一写到全局目录:
63 65 
64```text66```text
65-.msagent/logs/app.log67+~/.msagent/logs/app.log
66```68```
67 69 
68如果你想看到更详细的日志,可以开启:70如果你想看到更详细的日志,可以开启:
@@ -7,7 +7,7 @@
7| 命令 | 说明 |7| 命令 | 说明 |
8|---|--------------------------------------------------------|8|---|--------------------------------------------------------|
9| `/hotkeys` | 查看键盘快捷键说明。 |9| `/hotkeys` | 查看键盘快捷键说明。 |
10-| `/agents` | 打开 Agent 选择器。切换后会开启新会话,不会继承原 Agent 的对话上下文。 |10+| `/agents` | 打开 Agent 选择器。切换后会开启新会话,并将当前 Agent 记录到 workspace 项目状态中。 |
11| `/model` | 打开模型选择器。 |11| `/model` | 打开模型选择器。 |
12| `/threads` | 浏览并恢复历史会话线程;已执行过 offload 的线程会显示 `[history offloaded]`。 |12| `/threads` | 浏览并恢复历史会话线程;已执行过 offload 的线程会显示 `[history offloaded]`。 |
13| `/tools` | 查看当前可用工具。 |13| `/tools` | 查看当前可用工具。 |
@@ -44,7 +44,7 @@
44 44 
45## 长期记忆45## 长期记忆
46 46 
47-`msagent` 支持把需要跨会话保留的信息保存为项目本地记忆。记忆内容会写入当前工作目录的 `.msagent/memory.md`,后续会话启动和消息处理时会自动读取非空记忆并注入上下文。47+`msagent` 支持把需要跨会话保留的信息保存为项目记忆。记忆内容会写入 `~/.msagent/state/projects/<project-id>/memory.md`,后续会话启动和消息处理时会自动读取非空记忆并注入上下文。
48 48 
49| 命令 | 说明 |49| 命令 | 说明 |
50|---|---|50|---|---|
@@ -338,6 +338,7 @@ class AgentFactory:
338 self,338 self,
339 config: AgentConfig,339 config: AgentConfig,
340 working_dir: Path | None = None,340 working_dir: Path | None = None,
341+ project_state_dir: Path | None = None,
341 context_schema: type[Any] | None = None,342 context_schema: type[Any] | None = None,
342 mcp_client: Any | None = None,343 mcp_client: Any | None = None,
343 skills_dir: Path | list[Path] | None = None,344 skills_dir: Path | list[Path] | None = None,
@@ -352,6 +353,7 @@ class AgentFactory:
352 patch_third_party_prompt_defaults()353 patch_third_party_prompt_defaults()
353 patch_deepagents_windows_absolute_paths()354 patch_deepagents_windows_absolute_paths()
354 working_dir = (working_dir or Path.cwd()).resolve()355 working_dir = (working_dir or Path.cwd()).resolve()
356+ state_dir = project_state_dir.resolve() if project_state_dir is not None else working_dir
355 resolved_llm = llm_config or config.llm357 resolved_llm = llm_config or config.llm
356 retry_cfg = getattr(config, "retry", None)358 retry_cfg = getattr(config, "retry", None)
357 tool_retry_cfg = getattr(retry_cfg, "tool", None) if retry_cfg is not None else None359 tool_retry_cfg = getattr(retry_cfg, "tool", None) if retry_cfg is not None else None
@@ -419,7 +421,7 @@ class AgentFactory:
419 all_tools = [*runtime_tools, *mcp_tools]421 all_tools = [*runtime_tools, *mcp_tools]
420 422 
421 skills_sources = self._resolve_existing_paths(skills_dir)423 skills_sources = self._resolve_existing_paths(skills_dir)
422- memory_sources = self._resolve_memory_sources(working_dir)424+ memory_sources = self._resolve_memory_sources(state_dir)
423 enable_skills_middleware = self._should_enable_skills_middleware(425 enable_skills_middleware = self._should_enable_skills_middleware(
424 config=config,426 config=config,
425 skills_sources=skills_sources,427 skills_sources=skills_sources,
@@ -455,6 +457,7 @@ class AgentFactory:
455 middleware: list[AgentMiddleware[Any, Any, Any]] = []457 middleware: list[AgentMiddleware[Any, Any, Any]] = []
456 agent_backend = self._build_agent_backend(458 agent_backend = self._build_agent_backend(
457 working_dir,459 working_dir,
460+ conversation_history_dir=state_dir / "conversation_history",
458 enable_large_results=needs_large_results,461 enable_large_results=needs_large_results,
459 enable_conversation_history=needs_conversation_history,462 enable_conversation_history=needs_conversation_history,
460 )463 )
@@ -1069,20 +1072,21 @@ class AgentFactory:
1069 return any(pattern and not pattern.startswith("!") for pattern in patterns)1072 return any(pattern and not pattern.startswith("!") for pattern in patterns)
1070 1073 
1071 @staticmethod1074 @staticmethod
1072- def _resolve_memory_sources(working_dir: Path) -> list[str]:1075+ def _resolve_memory_sources(state_dir: Path) -> list[str]:
1073- memory_content = read_memory_file(working_dir)1076+ memory_content = read_memory_file(state_dir=state_dir)
1074 if not memory_content or is_default_memory_content(memory_content):1077 if not memory_content or is_default_memory_content(memory_content):
1075 return []1078 return []
1076 1079 
1077 from msagent.tools.internal.memory import ensure_memory_file1080 from msagent.tools.internal.memory import ensure_memory_file
1078 1081 
1079- memory_file = ensure_memory_file(working_dir)1082+ memory_file = ensure_memory_file(state_dir=state_dir)
1080 return [str(memory_file)]1083 return [str(memory_file)]
1081 1084 
1082 @staticmethod1085 @staticmethod
1083 def _build_agent_backend(1086 def _build_agent_backend(
1084 working_dir: Path,1087 working_dir: Path,
1085 *,1088 *,
1089+ conversation_history_dir: Path | None = None,
1086 enable_large_results: bool,1090 enable_large_results: bool,
1087 enable_conversation_history: bool,1091 enable_conversation_history: bool,
1088 ) -> CompositeBackend | LocalShellBackend:1092 ) -> CompositeBackend | LocalShellBackend:
@@ -1099,7 +1103,7 @@ class AgentFactory:
1099 )1103 )
1100 if enable_conversation_history:1104 if enable_conversation_history:
1101 routes["/conversation_history/"] = FilesystemBackend(1105 routes["/conversation_history/"] = FilesystemBackend(
1102- root_dir=working_dir / CONFIG_CONVERSATION_HISTORY_DIR,1106+ root_dir=conversation_history_dir or working_dir / CONFIG_CONVERSATION_HISTORY_DIR,
1103 virtual_mode=True,1107 virtual_mode=True,
1104 )1108 )
1105 if not routes:1109 if not routes:
@@ -25,7 +25,6 @@ from pathlib import Path
25from typing import Any, Iterator25from typing import Any, Iterator
26 26 
27from msagent.audit.events import AuditEventType27from msagent.audit.events import AuditEventType
28-from msagent.core.constants import CONFIG_AUDIT_DIR
29 28 
30 29 
31def iter_json_values(text: str) -> Iterator[dict[str, Any]]:30def iter_json_values(text: str) -> Iterator[dict[str, Any]]:
@@ -46,9 +45,16 @@ def iter_json_values(text: str) -> Iterator[dict[str, Any]]:
46 yield payload45 yield payload
47 46 
48 47 
49-def resolve_audit_path(*, working_dir: Path, thread_id: str) -> Path | None:48+def resolve_audit_path(
49+ *,
50+ working_dir: Path,
51+ thread_id: str,
52+ state_dir: Path | None = None,
53+) -> Path | None:
50 """Resolve the audit file for a session thread id."""54 """Resolve the audit file for a session thread id."""
51- audit_dir = working_dir.resolve() / CONFIG_AUDIT_DIR55+ audit_dir = (
56+ state_dir.resolve() / "audit_log" if state_dir is not None else working_dir.resolve() / ".msagent" / "audit_log"
57+ )
52 if not audit_dir.is_dir():58 if not audit_dir.is_dir():
53 return None59 return None
54 60 
@@ -65,9 +71,13 @@ def resolve_audit_path(*, working_dir: Path, thread_id: str) -> Path | None:
65class AuditReader:71class AuditReader:
66 """Load audit events and delegation summaries from disk."""72 """Load audit events and delegation summaries from disk."""
67 73 
68- def __init__(self, *, working_dir: Path, thread_id: str) -> None:74+ def __init__(self, *, working_dir: Path, thread_id: str, state_dir: Path | None = None) -> None:
69 self.thread_id = thread_id75 self.thread_id = thread_id
70- self.path = resolve_audit_path(working_dir=working_dir, thread_id=thread_id)76+ self.path = resolve_audit_path(
77+ working_dir=working_dir,
78+ thread_id=thread_id,
79+ state_dir=state_dir,
80+ )
71 81 
72 def exists(self) -> bool:82 def exists(self) -> bool:
73 return self.path is not None and self.path.is_file()83 return self.path is not None and self.path.is_file()
@@ -27,7 +27,6 @@ from typing import Any
27 27 
28from msagent.audit.events import AuditEvent, UserResponseEvent, UserTurnEvent28from msagent.audit.events import AuditEvent, UserResponseEvent, UserTurnEvent
29from msagent.audit.protocol import ProtocolParseResult29from msagent.audit.protocol import ProtocolParseResult
30-from msagent.core.constants import CONFIG_AUDIT_DIR
31 30 
32 31 
33def resolve_audit_log_enabled(agent_config: object | None) -> bool:32def resolve_audit_log_enabled(agent_config: object | None) -> bool:
@@ -59,6 +58,7 @@ class AuditWriter:
59 self,58 self,
60 *,59 *,
61 working_dir: Path,60 working_dir: Path,
61+ state_dir: Path | None = None,
62 thread_id: str,62 thread_id: str,
63 agent_name: str,63 agent_name: str,
64 enabled: bool | None = None,64 enabled: bool | None = None,
@@ -67,7 +67,9 @@ class AuditWriter:
67 self.thread_id = thread_id67 self.thread_id = thread_id
68 self.agent_name = agent_name68 self.agent_name = agent_name
69 self.enabled = False if enabled is None else enabled69 self.enabled = False if enabled is None else enabled
70- self._audit_dir = self.working_dir / CONFIG_AUDIT_DIR70+ self._audit_dir = (
71+ state_dir.resolve() / "audit_log" if state_dir is not None else self.working_dir / ".msagent" / "audit_log"
72+ )
71 self._path = self._audit_dir / build_audit_filename(73 self._path = self._audit_dir / build_audit_filename(
72 agent_name=self.agent_name,74 agent_name=self.agent_name,
73 thread_id=self.thread_id,75 thread_id=self.thread_id,
@@ -16,6 +16,8 @@ from msagent.cli.bootstrap.legacy import (
16)16)
17from msagent.cli.theme import console17from msagent.cli.theme import console
18from msagent.core.logging import configure_logging, get_logger18from msagent.core.logging import configure_logging, get_logger
19+from msagent.core.paths import AppPaths
20+from msagent.core.storage_layout import StorageLayoutError, validate_and_initialize_storage_layout
19 21 
20 22 
21def create_parser():23def create_parser():
@@ -45,7 +47,18 @@ async def main() -> int:
45 args = parser.parse_args(argv)47 args = parser.parse_args(argv)
46 48 
47 working_dir = Path(getattr(args, "working_dir", Path.cwd()))49 working_dir = Path(getattr(args, "working_dir", Path.cwd()))
48- configure_logging(show_logs=getattr(args, "verbose", False), working_dir=working_dir)50+ app_paths = AppPaths.resolve()
51+ try:
52+ validate_and_initialize_storage_layout(app_paths)
53+ except StorageLayoutError as exc:
54+ console.print_error(str(exc))
55+ return 1
56+ 
57+ configure_logging(
58+ show_logs=getattr(args, "verbose", False),
59+ working_dir=working_dir,
60+ log_dir=app_paths.logs_dir,
61+ )
49 logger = get_logger(__name__)62 logger = get_logger(__name__)
50 63 
51 try:64 try:
@@ -28,12 +28,7 @@ from msagent.configs import (
28 MCPConfig,28 MCPConfig,
29 ToolApprovalConfig,29 ToolApprovalConfig,
30)30)
31-from msagent.core.constants import (31+from msagent.core.paths import AppPaths, ProjectPaths
32- CONFIG_CHECKPOINTS_URL_FILE_NAME,
33- CONFIG_MCP_CACHE_DIR,
34- CONFIG_MCP_OAUTH_DIR,
35- CONFIG_SKILLS_DIR,
36-)
37from msagent.llms.factory import LLMFactory32from msagent.llms.factory import LLMFactory
38from msagent.mcp.factory import MCPFactory33from msagent.mcp.factory import MCPFactory
39from msagent.skills.factory import Skill, SkillFactory34from msagent.skills.factory import Skill, SkillFactory
@@ -49,7 +44,8 @@ if TYPE_CHECKING:
49class Initializer:44class Initializer:
50 """Centralized service for initializing and caching runtime resources."""45 """Centralized service for initializing and caching runtime resources."""
51 46 
52- def __init__(self) -> None:47+ def __init__(self, app_paths: AppPaths | None = None) -> None:
48+ self.app_paths = app_paths or AppPaths.resolve()
53 self.tool_factory = ToolFactory()49 self.tool_factory = ToolFactory()
54 self.skill_factory = SkillFactory()50 self.skill_factory = SkillFactory()
55 self.llm_factory = LLMFactory()51 self.llm_factory = LLMFactory()
@@ -67,9 +63,13 @@ class Initializer:
67 self._registries: dict[Path, ConfigRegistry] = {}63 self._registries: dict[Path, ConfigRegistry] = {}
68 64 
69 def get_registry(self, working_dir: Path) -> ConfigRegistry:65 def get_registry(self, working_dir: Path) -> ConfigRegistry:
70- if working_dir not in self._registries:66+ canonical = working_dir.expanduser().resolve()
71- self._registries[working_dir] = ConfigRegistry(working_dir)67+ if canonical not in self._registries:
72- return self._registries[working_dir]68+ self._registries[canonical] = ConfigRegistry(canonical, self.app_paths)
69+ return self._registries[canonical]
70+ 
71+ def get_project_paths(self, working_dir: Path) -> ProjectPaths:
72+ return self.app_paths.for_project(working_dir)
73 73 
74 async def load_llms_config(self, working_dir: Path) -> BatchLLMConfig:74 async def load_llms_config(self, working_dir: Path) -> BatchLLMConfig:
75 return await self.get_registry(working_dir).load_llms()75 return await self.get_registry(working_dir).load_llms()
@@ -95,8 +95,18 @@ class Initializer:
95 async def update_agent_llm(self, agent_name: str, new_llm_name: str, working_dir: Path) -> None:95 async def update_agent_llm(self, agent_name: str, new_llm_name: str, working_dir: Path) -> None:
96 await self.get_registry(working_dir).update_agent_llm(agent_name, new_llm_name)96 await self.get_registry(working_dir).update_agent_llm(agent_name, new_llm_name)
97 97 
98+ async def set_current_agent(self, agent_name: str, working_dir: Path) -> None:
99+ await self.get_registry(working_dir).set_current_agent(agent_name)
100+ 
101+ async def get_current_model(self, agent_name: str, working_dir: Path) -> str | None:
102+ return await self.get_registry(working_dir).get_current_model(agent_name)
103+ 
104+ async def set_current_model(self, agent_name: str, model_name: str, working_dir: Path) -> None:
105+ await self.get_registry(working_dir).set_current_model(agent_name, model_name)
106+ 
98 async def update_default_agent(self, agent_name: str, working_dir: Path) -> None:107 async def update_default_agent(self, agent_name: str, working_dir: Path) -> None:
99- await self.get_registry(working_dir).update_default_agent(agent_name)108+ """Compatibility alias for workspace-scoped Agent selection."""
109+ await self.set_current_agent(agent_name, working_dir)
100 110 
101 async def add_agent_skill_pattern(self, agent_name: str, skill_pattern: str, working_dir: Path) -> bool:111 async def add_agent_skill_pattern(self, agent_name: str, skill_pattern: str, working_dir: Path) -> bool:
102 return await self.get_registry(working_dir).add_agent_skill_pattern(agent_name, skill_pattern)112 return await self.get_registry(working_dir).add_agent_skill_pattern(agent_name, skill_pattern)
@@ -112,9 +122,11 @@ class Initializer:
112 async def get_checkpointer(self, agent: str, working_dir: Path) -> AsyncIterator[BaseCheckpointSaver]:122 async def get_checkpointer(self, agent: str, working_dir: Path) -> AsyncIterator[BaseCheckpointSaver]:
113 """Open the configured checkpointer for a given agent."""123 """Open the configured checkpointer for a given agent."""
114 agent_config = await self.load_agent_config(agent, working_dir)124 agent_config = await self.load_agent_config(agent, working_dir)
125+ project_paths = self.get_project_paths(working_dir)
126+ await asyncio.to_thread(project_paths.ensure)
115 checkpointer_ctx = self._create_checkpointer(127 checkpointer_ctx = self._create_checkpointer(
116 cast(CheckpointerConfig | None, agent_config.checkpointer),128 cast(CheckpointerConfig | None, agent_config.checkpointer),
117- str(working_dir / CONFIG_CHECKPOINTS_URL_FILE_NAME),129+ str(project_paths.checkpoints_db),
118 )130 )
119 checkpointer = await checkpointer_ctx.__aenter__()131 checkpointer = await checkpointer_ctx.__aenter__()
120 try:132 try:
@@ -145,6 +157,7 @@ class Initializer:
145 return fake_graph, fake_cleanup157 return fake_graph, fake_cleanup
146 158 
147 registry = self.get_registry(working_dir)159 registry = self.get_registry(working_dir)
160+ project_paths = getattr(registry, "project_paths", self.get_project_paths(working_dir))
148 161 
149 with timer("Load configs"):162 with timer("Load configs"):
150 if model:163 if model:
@@ -171,7 +184,7 @@ class Initializer:
171 with timer("Create checkpointer"):184 with timer("Create checkpointer"):
172 checkpointer_ctx = self._create_checkpointer(185 checkpointer_ctx = self._create_checkpointer(
173 cast(CheckpointerConfig | None, agent_config.checkpointer),186 cast(CheckpointerConfig | None, agent_config.checkpointer),
174- str(working_dir / CONFIG_CHECKPOINTS_URL_FILE_NAME),187+ str(project_paths.checkpoints_db),
175 )188 )
176 checkpointer = await checkpointer_ctx.__aenter__()189 checkpointer = await checkpointer_ctx.__aenter__()
177 190 
@@ -181,8 +194,8 @@ class Initializer:
181 )194 )
182 mcp_client = await self.mcp_factory.create(195 mcp_client = await self.mcp_factory.create(
183 config=mcp_config,196 config=mcp_config,
184- cache_dir=working_dir / CONFIG_MCP_CACHE_DIR,197+ cache_dir=self.app_paths.mcp_cache_dir,
185- oauth_dir=working_dir / CONFIG_MCP_OAUTH_DIR,198+ oauth_dir=self.app_paths.mcp_oauth_dir,
186 sandbox_bindings=None,199 sandbox_bindings=None,
187 default_invoke_timeout=default_timeout,200 default_invoke_timeout=default_timeout,
188 )201 )
@@ -206,6 +219,7 @@ class Initializer:
206 graph = await self.agent_factory.create(219 graph = await self.agent_factory.create(
207 config=agent_config,220 config=agent_config,
208 working_dir=working_dir,221 working_dir=working_dir,
222+ project_state_dir=project_paths.root,
209 context_schema=AgentContext,223 context_schema=AgentContext,
210 checkpointer=checkpointer,224 checkpointer=checkpointer,
211 mcp_client=mcp_client,225 mcp_client=mcp_client,
@@ -236,8 +250,8 @@ class Initializer:
236 def _resolve_skills_dirs(self, working_dir: Path) -> list[Path]:250 def _resolve_skills_dirs(self, working_dir: Path) -> list[Path]:
237 candidates = [251 candidates = [
238 working_dir / "skills",252 working_dir / "skills",
253+ self.app_paths.skills_dir,
239 self.skill_factory.get_default_skills_dir(),254 self.skill_factory.get_default_skills_dir(),
240- working_dir / CONFIG_SKILLS_DIR,
241 ]255 ]
242 256 
243 unique_paths: list[Path] = []257 unique_paths: list[Path] = []
@@ -15,7 +15,7 @@ from msagent.cli.bootstrap.initializer import initializer
15from msagent.cli.theme import console15from msagent.cli.theme import console
16from msagent.configs import ApprovalMode16from msagent.configs import ApprovalMode
17from msagent.configs.llm import LLMProvider17from msagent.configs.llm import LLMProvider
18-from msagent.core.constants import APP_NAME, CONFIG_LLMS_FILE_NAME18+from msagent.core.constants import APP_NAME
19 19 
20LEGACY_PROVIDER_MAP = {20LEGACY_PROVIDER_MAP = {
21 "openai": LLMProvider.OPENAI,21 "openai": LLMProvider.OPENAI,
@@ -59,7 +59,7 @@ Optional arguments:
59 -V, --version Show version information and exit.59 -V, --version Show version information and exit.
60 --stream Stream output.60 --stream Stream output.
61 --no-stream Render the final reply without token streaming.61 --no-stream Render the final reply without token streaming.
62- -v, --verbose Enable verbose logging to console and .msagent/app.log.62+ -v, --verbose Enable verbose logging to console and ~/.msagent/logs.
63 -w, --working-dir <DIR> Working directory for the session [default: current directory]63 -w, --working-dir <DIR> Working directory for the session [default: current directory]
64 -a, --agent <NAME> Agent name. Available: Profiler, Accuracy, Quantizer, Modeling, Operator, Minos.64 -a, --agent <NAME> Agent name. Available: Profiler, Accuracy, Quantizer, Modeling, Operator, Minos.
65 -m, --model <NAME> LLM model alias.65 -m, --model <NAME> LLM model alias.
@@ -96,14 +96,14 @@ Usage:
96 96 
97Optional arguments:97Optional arguments:
98 -h, --help Show help message and exit.98 -h, --help Show help message and exit.
99- -v, --verbose Enable verbose logging to console and .msagent/app.log.99+ -v, --verbose Enable verbose logging to console and ~/.msagent/logs.
100 -s, --show Show current configuration.100 -s, --show Show current configuration.
101 --llm-provider <NAME> LLM provider. Available: openai, anthropic, gemini, google.101 --llm-provider <NAME> LLM provider. Available: openai, anthropic, gemini, google.
102 --llm-api-key <KEY> LLM API key for this process only.102 --llm-api-key <KEY> LLM API key for this process only.
103 --llm-max-tokens <INT> Max output tokens (0 means provider/model default).103 --llm-max-tokens <INT> Max output tokens (0 means provider/model default).
104 --llm-base-url <URL> Custom provider base URL for a compatible service or proxy.104 --llm-base-url <URL> Custom provider base URL for a compatible service or proxy.
105 -m, --llm-model <NAME> Model name.105 -m, --llm-model <NAME> Model name.
106- -w, --working-dir <DIR> Working directory for project-local .msagent config [default: current directory]106+ -w, --working-dir <DIR> Project working directory [default: current directory]
107 107 
108Examples:108Examples:
109 # Show current project-local configuration109 # Show current project-local configuration
@@ -211,7 +211,7 @@ def create_legacy_parser() -> argparse.ArgumentParser:
211 "-v",211 "-v",
212 "--verbose",212 "--verbose",
213 action="store_true",213 action="store_true",
214- help="Enable verbose logging to console and .msagent/app.log",214+ help="Enable verbose logging to console and ~/.msagent/logs",
215 )215 )
216 config_parser.add_argument(216 config_parser.add_argument(
217 "--show",217 "--show",
@@ -238,7 +238,7 @@ def create_legacy_parser() -> argparse.ArgumentParser:
238 "-w",238 "-w",
239 "--working-dir",239 "--working-dir",
240 default=os.getcwd(),240 default=os.getcwd(),
241- help="Working directory for project-local .msagent config",241+ help="Project working directory",
242 )242 )
243 243 
244 return parser244 return parser
@@ -275,7 +275,7 @@ def create_session_parser() -> argparse.ArgumentParser:
275 "-v",275 "-v",
276 "--verbose",276 "--verbose",
277 action="store_true",277 action="store_true",
278- help="Enable verbose logging to console and .msagent/app.log",278+ help="Enable verbose logging to console and ~/.msagent/logs",
279 )279 )
280 _add_runtime_options(parser, include_timer=True)280 _add_runtime_options(parser, include_timer=True)
281 return parser281 return parser
@@ -387,13 +387,13 @@ async def _handle_config(args: argparse.Namespace) -> int:
387 "context_window": current_llm.context_window,387 "context_window": current_llm.context_window,
388 }388 }
389 389 
390- llm_config_path = working_dir / CONFIG_LLMS_FILE_NAME390+ llm_config_path = registry.llms_file
391+ llm_config_path.parent.mkdir(parents=True, exist_ok=True)
391 llm_config_path.write_text(392 llm_config_path.write_text(
392 yaml.safe_dump({"llms": [llm_data]}, sort_keys=False, allow_unicode=True),393 yaml.safe_dump({"llms": [llm_data]}, sort_keys=False, allow_unicode=True),
393 encoding="utf-8",394 encoding="utf-8",
394 )395 )
395 396 
396- await registry.update_agent_llm(agent_config.name, "default")
397 registry.invalidate_cache()397 registry.invalidate_cache()
398 398 
399 if args.llm_api_key:399 if args.llm_api_key:
@@ -408,7 +408,8 @@ async def _handle_config(args: argparse.Namespace) -> int:
408 408 
409async def _show_config(registry, working_dir: Path) -> int:409async def _show_config(registry, working_dir: Path) -> int:
410 agent_config = await registry.get_agent(None)410 agent_config = await registry.get_agent(None)
411- llm_config = agent_config.llm411+ workspace_model = await registry.get_current_model(agent_config.name)
412+ llm_config = await registry.get_llm(workspace_model) if workspace_model else agent_config.llm
412 mcp_config = await registry.load_mcp()413 mcp_config = await registry.load_mcp()
413 414 
414 provider_label = "gemini" if llm_config.provider == LLMProvider.GOOGLE else llm_config.provider.value415 provider_label = "gemini" if llm_config.provider == LLMProvider.GOOGLE else llm_config.provider.value
@@ -449,5 +450,5 @@ async def _show_config(registry, working_dir: Path) -> int:
449 )450 )
450 console.print(mcp_table)451 console.print(mcp_table)
451 452 
452- console.print(f"\n[dim]Config dir: {working_dir / '.msagent'}[/dim]")453+ console.print(f"\n[dim]Config dir: {registry.config_dir}[/dim]")
453 return 0454 return 0
@@ -23,6 +23,7 @@ class Context(BaseModel):
23 model_display: str | None = None23 model_display: str | None = None
24 thread_id: str24 thread_id: str
25 working_dir: Path25 working_dir: Path
26+ state_dir: Path | None = None
26 approval_mode: ApprovalMode = ApprovalMode.ACTIVE27 approval_mode: ApprovalMode = ApprovalMode.ACTIVE
27 bash_mode: bool = False28 bash_mode: bool = False
28 current_input_tokens: int | None = None29 current_input_tokens: int | None = None
@@ -72,17 +73,21 @@ class Context(BaseModel):
72 agent_config = await initializer.load_agent_config(agent, working_dir)73 agent_config = await initializer.load_agent_config(agent, working_dir)
73 74 
74 thread_id = str(uuid.uuid4())75 thread_id = str(uuid.uuid4())
76+ resolved_agent = agent_config.name
77+ workspace_model = None
78+ if model is None:
79+ workspace_model = await initializer.get_current_model(resolved_agent, working_dir)
80+ selected_model = model or workspace_model
75 81 
76- if model:82+ if selected_model:
77 with timer("Load LLM config"):83 with timer("Load LLM config"):
78- llm_config = await initializer.load_llm_config(model, working_dir)84+ llm_config = await initializer.load_llm_config(selected_model, working_dir)
79 else:85 else:
80 llm_config = agent_config.llm86 llm_config = agent_config.llm
81 87 
82 tool_output_max_tokens = agent_config.tools.output_max_tokens if agent_config.tools else None88 tool_output_max_tokens = agent_config.tools.output_max_tokens if agent_config.tools else None
83 89 
84- resolved_agent = agent or agent_config.name90+ resolved_model = selected_model or agent_config.llm.alias
85- resolved_model = model or agent_config.llm.alias
86 resolved_model_display = cls.format_model_display(resolved_model, llm_config)91 resolved_model_display = cls.format_model_display(resolved_model, llm_config)
87 92 
88 logger.info(93 logger.info(
@@ -100,6 +105,7 @@ class Context(BaseModel):
100 model_display=resolved_model_display,105 model_display=resolved_model_display,
101 thread_id=thread_id,106 thread_id=thread_id,
102 working_dir=working_dir,107 working_dir=working_dir,
108+ state_dir=initializer.get_project_paths(working_dir).root,
103 approval_mode=approval_mode or ApprovalMode.ACTIVE,109 approval_mode=approval_mode or ApprovalMode.ACTIVE,
104 context_window=llm_config.context_window,110 context_window=llm_config.context_window,
105 recursion_limit=agent_config.recursion_limit,111 recursion_limit=agent_config.recursion_limit,
@@ -67,6 +67,7 @@ class Session:
67 )67 )
68 68 
69 self.audit_writer = AuditWriter(69 self.audit_writer = AuditWriter(
70+ state_dir=context.state_dir,
70 working_dir=context.working_dir,71 working_dir=context.working_dir,
71 thread_id=context.thread_id,72 thread_id=context.thread_id,
72 agent_name=context.agent,73 agent_name=context.agent,
@@ -27,7 +27,10 @@ class AddSkillHandler:
27 self.session.context.agent,27 self.session.context.agent,
28 self.session.context.working_dir,28 self.session.context.working_dir,
29 )29 )
30- installer = SkillInstaller(self.session.context.working_dir)30+ installer = SkillInstaller(
31+ self.session.context.working_dir,
32+ skills_dir=initializer.app_paths.skills_dir,
33+ )
31 result = await installer.install(args[0])34 result = await installer.install(args[0])
32 await initializer.add_agent_skill_pattern(35 await initializer.add_agent_skill_pattern(
33 current_agent.name,36 current_agent.name,
@@ -47,28 +47,45 @@ class AgentHandler:
47 selected_agent_config = await initializer.load_agent_config(47 selected_agent_config = await initializer.load_agent_config(
48 selected_agent_name, self.session.context.working_dir48 selected_agent_name, self.session.context.working_dir
49 )49 )
50+ selected_model = await initializer.get_current_model(
51+ selected_agent_name,
52+ self.session.context.working_dir,
53+ )
54+ selected_llm = (
55+ await initializer.load_llm_config(
56+ selected_model,
57+ self.session.context.working_dir,
58+ )
59+ if selected_model
60+ else selected_agent_config.llm
61+ )
62+ selected_model = selected_model or selected_agent_config.llm.alias
63+ 
64+ await initializer.set_current_agent(
65+ selected_agent_name,
66+ self.session.context.working_dir,
67+ )
50 68 
51 # Update context with both agent and its configured model69 # Update context with both agent and its configured model
52 self.session.update_context(70 self.session.update_context(
53 agent=selected_agent_name,71 agent=selected_agent_name,
54 agent_description=selected_agent_config.description or None,72 agent_description=selected_agent_config.description or None,
55- model=selected_agent_config.llm.alias,73+ model=selected_model,
56 model_display=Context.format_model_display(74 model_display=Context.format_model_display(
57- selected_agent_config.llm.alias, selected_agent_config.llm75+ selected_model,
76+ selected_llm,
58 ),77 ),
78+ context_window=getattr(selected_llm, "context_window", None),
59 audit_log_enabled=resolve_audit_log_enabled(selected_agent_config),79 audit_log_enabled=resolve_audit_log_enabled(selected_agent_config),
60 # Agent prompts must not inherit another agent's checkpointed conversation.80 # Agent prompts must not inherit another agent's checkpointed conversation.
61 thread_id=str(uuid.uuid4()),81 thread_id=str(uuid.uuid4()),
62 current_input_tokens=None,82 current_input_tokens=None,
63 current_output_tokens=None,83 current_output_tokens=None,
64 )84 )
65- logger.info(f"Switched to Agent: {selected_agent_name}, Model: {selected_agent_config.llm.alias}")85+ logger.info(f"Switched to Agent: {selected_agent_name}, Model: {selected_model}")
66 console.print_warning(f"Switched to Agent: {selected_agent_name}. A new conversation has started.")86 console.print_warning(f"Switched to Agent: {selected_agent_name}. A new conversation has started.")
67 console.print("")87 console.print("")
68 88 
69- # Mark this agent as the new default
70- await initializer.update_default_agent(selected_agent_name, self.session.context.working_dir)
71- 
72 except Exception as e:89 except Exception as e:
73 console.print_error(f"Error switching agents: {e}")90 console.print_error(f"Error switching agents: {e}")
74 console.print("")91 console.print("")
@@ -47,15 +47,31 @@ class MemoryHandler:
47 console.print("")47 console.print("")
48 return48 return
49 49 
50- memory_path = await asyncio.to_thread(append_memory_entry, content, self.session.context.working_dir)50+ context = self.session.context
51+ memory_path = await asyncio.to_thread(
52+ append_memory_entry,
53+ content,
54+ context.working_dir,
55+ state_dir=getattr(context, "state_dir", None),
56+ )
51 console.print_success(f"Saved memory to {memory_path}")57 console.print_success(f"Saved memory to {memory_path}")
52 console.print("")58 console.print("")
53 59 
54 async def show(self, args: list[str]) -> None:60 async def show(self, args: list[str]) -> None:
55 """Show current project memory."""61 """Show current project memory."""
56 _ = args62 _ = args
57- await asyncio.to_thread(ensure_memory_file, self.session.context.working_dir)63+ context = self.session.context
58- content = await asyncio.to_thread(read_memory_file, self.session.context.working_dir)64+ state_dir = getattr(context, "state_dir", None)
65+ await asyncio.to_thread(
66+ ensure_memory_file,
67+ context.working_dir,
68+ state_dir=state_dir,
69+ )
70+ content = await asyncio.to_thread(
71+ read_memory_file,
72+ context.working_dir,
73+ state_dir=state_dir,
74+ )
59 content = content.strip()75 content = content.strip()
60 if not content or is_default_memory_content(content):76 if not content or is_default_memory_content(content):
61 console.print_warning("No memory saved yet")77 console.print_warning("No memory saved yet")
@@ -53,7 +53,7 @@ class ModelHandler:
53 selected_model_name = await self._get_model_selection(models, current_model_name, default_model_name)53 selected_model_name = await self._get_model_selection(models, current_model_name, default_model_name)
54 54 
55 if selected_model_name:55 if selected_model_name:
56- await initializer.update_agent_llm(56+ await initializer.set_current_model(
57 self.session.context.agent,57 self.session.context.agent,
58 selected_model_name,58 selected_model_name,
59 self.session.context.working_dir,59 self.session.context.working_dir,
@@ -21,7 +21,6 @@ from msagent.cli.ui.shared import (
21 create_bottom_toolbar,21 create_bottom_toolbar,
22 create_prompt_style,22 create_prompt_style,
23)23)
24-from msagent.core.constants import CONFIG_HISTORY_FILE_NAME
25from msagent.core.logging import get_logger24from msagent.core.logging import get_logger
26from msagent.core.settings import settings25from msagent.core.settings import settings
27 26 
@@ -35,7 +34,8 @@ class InteractivePrompt:
35 self.context = context34 self.context = context
36 self.commands = commands35 self.commands = commands
37 self.session = session36 self.session = session
38- history_file = Path(context.working_dir) / CONFIG_HISTORY_FILE_NAME37+ state_dir = Path(context.state_dir) if context.state_dir is not None else Path(context.working_dir) / ".msagent"
38+ history_file = state_dir / "history"
39 history_file.parent.mkdir(parents=True, exist_ok=True)39 history_file.parent.mkdir(parents=True, exist_ok=True)
40 self.history = FileHistory(str(history_file))40 self.history = FileHistory(str(history_file))
41 self.prompt_session: PromptSession[str]41 self.prompt_session: PromptSession[str]
@@ -8,7 +8,7 @@ from pathlib import Path
8from typing import Any, Literal8from typing import Any, Literal
9 9 
10import yaml10import yaml
11-from packaging import version as pkg_version11+from packaging.version import parse as parse_version # pylint: disable=no-name-in-module
12from pydantic import BaseModel, Field, model_validator12from pydantic import BaseModel, Field, model_validator
13 13 
14from msagent.configs.base import VersionedConfig14from msagent.configs.base import VersionedConfig
@@ -53,7 +53,7 @@ class CompressionConfig(BaseModel):
53 53 
54 54 
55class AuditLogConfig(BaseModel):55class AuditLogConfig(BaseModel):
56- """Record session and subagent events to ``.msagent/audit_log/``."""56+ """Record session and subagent events to the project audit directory."""
57 57 
58 enabled: bool = Field(58 enabled: bool = Field(
59 default=False,59 default=False,
@@ -285,10 +285,10 @@ class BaseAgentConfig(VersionedConfig):
285 @classmethod285 @classmethod
286 def migrate(cls, data: dict, from_version: str) -> dict:286 def migrate(cls, data: dict, from_version: str) -> dict:
287 """Migrate config data from older version."""287 """Migrate config data from older version."""
288- from_ver = pkg_version.parse(from_version)288+ from_ver = parse_version(from_version)
289 289 
290 # Migrate 1.x -> 2.0.0: tools: list[str] -> tools: ToolsConfig290 # Migrate 1.x -> 2.0.0: tools: list[str] -> tools: ToolsConfig
291- if from_ver < pkg_version.parse("2.0.0"):291+ if from_ver < parse_version("2.0.0"):
292 tool_output_max_tokens = data.pop("tool_output_max_tokens", None)292 tool_output_max_tokens = data.pop("tool_output_max_tokens", None)
293 293 
294 if "tools" in data and isinstance(data["tools"], list):294 if "tools" in data and isinstance(data["tools"], list):
@@ -314,7 +314,7 @@ class BaseAgentConfig(VersionedConfig):
314 }314 }
315 315 
316 # Migrate 2.0.0 -> 2.1.0: add skills: SkillsConfig316 # Migrate 2.0.0 -> 2.1.0: add skills: SkillsConfig
317- if from_ver < pkg_version.parse("2.1.0"):317+ if from_ver < parse_version("2.1.0"):
318 if "skills" not in data:318 if "skills" not in data:
319 data["skills"] = {319 data["skills"] = {
320 "patterns": [],320 "patterns": [],
@@ -322,7 +322,7 @@ class BaseAgentConfig(VersionedConfig):
322 }322 }
323 323 
324 # Migrate 2.1.0 -> 2.2.0: rename compression_llm->llm and add prompt/messages_to_keep324 # Migrate 2.1.0 -> 2.2.0: rename compression_llm->llm and add prompt/messages_to_keep
325- if from_ver < pkg_version.parse("2.2.0") and (compression := data.get("compression")):325+ if from_ver < parse_version("2.2.0") and (compression := data.get("compression")):
326 if isinstance(compression, dict):326 if isinstance(compression, dict):
327 if "compression_llm" in compression and "llm" not in compression:327 if "compression_llm" in compression and "llm" not in compression:
328 compression["llm"] = compression.pop("compression_llm")328 compression["llm"] = compression.pop("compression_llm")
@@ -337,11 +337,11 @@ class BaseAgentConfig(VersionedConfig):
337 cls._copy_missing_prompts(default_prompts)337 cls._copy_missing_prompts(default_prompts)
338 338 
339 # Migrate 2.2.0 -> 2.2.1: copy default sandbox profiles if missing339 # Migrate 2.2.0 -> 2.2.1: copy default sandbox profiles if missing
340- if from_ver < pkg_version.parse("2.2.1"):340+ if from_ver < parse_version("2.2.1"):
341 cls._copy_missing_sandbox_profiles()341 cls._copy_missing_sandbox_profiles()
342 342 
343 # Migrate 2.2.1 -> 2.3.0: add retry configuration (legacy shape)343 # Migrate 2.2.1 -> 2.3.0: add retry configuration (legacy shape)
344- if from_ver < pkg_version.parse("2.3.0"):344+ if from_ver < parse_version("2.3.0"):
345 if "retry" not in data:345 if "retry" not in data:
346 data["retry"] = {346 data["retry"] = {
347 "enabled": True,347 "enabled": True,
@@ -354,7 +354,7 @@ class BaseAgentConfig(VersionedConfig):
354 }354 }
355 355 
356 # Migrate 2.3.0 -> 2.4.0: align retry schema with deepagents parameters356 # Migrate 2.3.0 -> 2.4.0: align retry schema with deepagents parameters
357- if from_ver < pkg_version.parse("2.4.0"):357+ if from_ver < parse_version("2.4.0"):
358 default_retry: dict[str, Any] = {358 default_retry: dict[str, Any] = {
359 "enabled": True,359 "enabled": True,
360 "model": {360 "model": {
@@ -595,6 +595,8 @@ class BatchAgentConfig(BaseBatchConfig):
595 cls,595 cls,
596 file_path: Path | None = None,596 file_path: Path | None = None,
597 dir_path: Path | None = None,597 dir_path: Path | None = None,
598+ prompt_base_path: Path | None = None,
599+ allow_partial: bool = False,
598 batch_llm_config: BatchLLMConfig | None = None,600 batch_llm_config: BatchLLMConfig | None = None,
599 batch_checkpointer_config: BatchCheckpointerConfig | None = None,601 batch_checkpointer_config: BatchCheckpointerConfig | None = None,
600 batch_subagent_config: BatchSubAgentConfig | None = None,602 batch_subagent_config: BatchSubAgentConfig | None = None,
@@ -602,11 +604,11 @@ class BatchAgentConfig(BaseBatchConfig):
602 ) -> BatchAgentConfig:604 ) -> BatchAgentConfig:
603 """Load agent configurations from YAML files."""605 """Load agent configurations from YAML files."""
604 agents = []606 agents = []
605- prompt_base_path = None607+ resolved_prompt_base_path = prompt_base_path
606 608 
607 if file_path and file_path.exists():609 if file_path and file_path.exists():
608 agents.extend(await _load_single_file(file_path, "agents", AgentConfig))610 agents.extend(await _load_single_file(file_path, "agents", AgentConfig))
609- prompt_base_path = file_path.parent611+ resolved_prompt_base_path = resolved_prompt_base_path or file_path.parent
610 612 
611 if dir_path and dir_path.exists():613 if dir_path and dir_path.exists():
612 agents.extend(614 agents.extend(
@@ -617,7 +619,7 @@ class BatchAgentConfig(BaseBatchConfig):
617 config_class=AgentConfig,619 config_class=AgentConfig,
618 )620 )
619 )621 )
620- prompt_base_path = dir_path.parent622+ resolved_prompt_base_path = resolved_prompt_base_path or dir_path.parent
621 623 
622 if not agents:624 if not agents:
623 raise ValueError("No agents found in YAML file")625 raise ValueError("No agents found in YAML file")
@@ -627,7 +629,10 @@ class BatchAgentConfig(BaseBatchConfig):
627 validated_agents: list[AgentConfig] = []629 validated_agents: list[AgentConfig] = []
628 for agent in agents:630 for agent in agents:
629 if prompt_content := agent.get("prompt", ""):631 if prompt_content := agent.get("prompt", ""):
630- agent["prompt"] = await load_prompt_content(prompt_base_path or Path(), prompt_content)632+ agent["prompt"] = await load_prompt_content(
633+ resolved_prompt_base_path or Path(),
634+ prompt_content,
635+ )
631 636 
632 if batch_llm_config and isinstance(agent.get("llm"), str):637 if batch_llm_config and isinstance(agent.get("llm"), str):
633 llm_name = agent["llm"]638 llm_name = agent["llm"]
@@ -685,12 +690,17 @@ class BatchAgentConfig(BaseBatchConfig):
685 compression["llm"] = agent["llm"]690 compression["llm"] = agent["llm"]
686 691 
687 if prompt_content := compression.get("prompt"):692 if prompt_content := compression.get("prompt"):
688- compression["prompt"] = await load_prompt_content(prompt_base_path or Path(), prompt_content)693+ compression["prompt"] = await load_prompt_content(
694+ resolved_prompt_base_path or Path(),
695+ prompt_content,
696+ )
689 else:697 else:
690 compression["prompt"] = None698 compression["prompt"] = None
691 699 
692 validated_agents.append(AgentConfig.model_validate(agent))700 validated_agents.append(AgentConfig.model_validate(agent))
693 701 
702+ if allow_partial:
703+ return cls.model_construct(agents=validated_agents)
694 return cls.model_validate({"agents": validated_agents})704 return cls.model_validate({"agents": validated_agents})
695 705 
696 706 
@@ -712,15 +722,16 @@ class BatchSubAgentConfig(BaseBatchConfig):
712 cls,722 cls,
713 file_path: Path | None = None,723 file_path: Path | None = None,
714 dir_path: Path | None = None,724 dir_path: Path | None = None,
725+ prompt_base_path: Path | None = None,
715 batch_llm_config: BatchLLMConfig | None = None,726 batch_llm_config: BatchLLMConfig | None = None,
716 ) -> BatchSubAgentConfig:727 ) -> BatchSubAgentConfig:
717 """Load subagent configurations from YAML files."""728 """Load subagent configurations from YAML files."""
718 subagents = []729 subagents = []
719- prompt_base_path = None730+ resolved_prompt_base_path = prompt_base_path
720 731 
721 if file_path and file_path.exists():732 if file_path and file_path.exists():
722 subagents.extend(await _load_single_file(file_path, "agents", SubAgentConfig))733 subagents.extend(await _load_single_file(file_path, "agents", SubAgentConfig))
723- prompt_base_path = file_path.parent734+ resolved_prompt_base_path = resolved_prompt_base_path or file_path.parent
724 735 
725 if dir_path and dir_path.exists():736 if dir_path and dir_path.exists():
726 subagents.extend(737 subagents.extend(
@@ -731,7 +742,7 @@ class BatchSubAgentConfig(BaseBatchConfig):
731 config_class=SubAgentConfig,742 config_class=SubAgentConfig,
732 )743 )
733 )744 )
734- prompt_base_path = dir_path.parent745+ resolved_prompt_base_path = resolved_prompt_base_path or dir_path.parent
735 746 
736 if not subagents:747 if not subagents:
737 raise ValueError("No subagents found in YAML file")748 raise ValueError("No subagents found in YAML file")
@@ -741,7 +752,10 @@ class BatchSubAgentConfig(BaseBatchConfig):
741 validated_subagents: list[SubAgentConfig] = []752 validated_subagents: list[SubAgentConfig] = []
742 for subagent in subagents:753 for subagent in subagents:
743 if prompt_content := subagent.get("prompt", ""):754 if prompt_content := subagent.get("prompt", ""):
744- subagent["prompt"] = await load_prompt_content(prompt_base_path or Path(), prompt_content)755+ subagent["prompt"] = await load_prompt_content(
756+ resolved_prompt_base_path or Path(),
757+ prompt_content,
758+ )
745 759 
746 if batch_llm_config and isinstance(subagent.get("llm"), str):760 if batch_llm_config and isinstance(subagent.get("llm"), str):
747 llm_name = subagent["llm"]761 llm_name = subagent["llm"]
@@ -3,12 +3,12 @@
3from __future__ import annotations3from __future__ import annotations
4 4 
5import asyncio5import asyncio
6-import json
7-import shutil
8from importlib.resources import files6from importlib.resources import files
9from pathlib import Path7from pathlib import Path
8+from typing import Any, Callable, TypeVar
10 9 
11import yaml10import yaml
11+from pydantic import BaseModel
12 12 
13from msagent.configs.agent import (13from msagent.configs.agent import (
14 AgentConfig,14 AgentConfig,
@@ -23,7 +23,6 @@ from logging.handlers import TimedRotatingFileHandler
23from pathlib import Path23from pathlib import Path
24from typing import Any, cast24from typing import Any, cast
25 25 
26-from msagent.core.constants import CONFIG_LOG_DIR
27from msagent.core.settings import settings26from msagent.core.settings import settings
28 27 
29# File format includes full context (for log file)28# File format includes full context (for log file)
@@ -58,12 +57,17 @@ def _install_unraisablehook_filter() -> None:
58 sys.unraisablehook = _filtered_hook57 sys.unraisablehook = _filtered_hook
59 58 
60 59 
61-def configure_logging(show_logs: bool = False, working_dir: Path | None = None) -> None:60+def configure_logging(
61+ show_logs: bool = False,
62+ working_dir: Path | None = None,
63+ log_dir: Path | None = None,
64+) -> None:
62 """Configure application logging.65 """Configure application logging.
63 66 
64 Args:67 Args:
65 show_logs: Enable file logging and show log location hint.68 show_logs: Enable file logging and show log location hint.
66- working_dir: Working directory for log file.69+ working_dir: Legacy base directory used when ``log_dir`` is omitted.
70+ log_dir: Explicit global log directory.
67 """71 """
68 if working_dir is None:72 if working_dir is None:
69 working_dir = Path.cwd()73 working_dir = Path.cwd()
@@ -113,7 +117,7 @@ def configure_logging(show_logs: bool = False, working_dir: Path | None = None)
113 )117 )
114 118 
115 # Always write logs to disk so they are available without -v.119 # Always write logs to disk so they are available without -v.
116- log_dir = working_dir / CONFIG_LOG_DIR120+ log_dir = log_dir or working_dir / ".msagent" / "logs"
117 log_file_path = log_dir / "app.log"121 log_file_path = log_dir / "app.log"
118 try:122 try:
119 log_dir.mkdir(parents=True, exist_ok=True)123 log_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,232 @@
1+"""Application and project path layout."""
2+ 
3+from __future__ import annotations
4+ 
5+import hashlib
6+import json
7+import os
8+import re
9+from dataclasses import dataclass
10+from pathlib import Path
11+from typing import Mapping
12+ 
13+ 
14+def _ensure_private_dir(path: Path) -> None:
15+ path.mkdir(parents=True, exist_ok=True)
16+ try:
17+ path.chmod(0o700)
18+ except OSError:
19+ pass
20+ 
21+ 
22+def _project_slug(path: Path) -> str:
23+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", path.name).strip("-.")
24+ return slug or "project"
25+ 
26+ 
27+@dataclass(frozen=True, slots=True)
28+class AppPaths:
29+ """Global msAgent directories rooted at ``MSAGENT_HOME``."""
30+ 
31+ home: Path
32+ 
33+ @classmethod
34+ def resolve(cls, env: Mapping[str, str] | None = None) -> AppPaths:
35+ values = os.environ if env is None else env
36+ configured = values.get("MSAGENT_HOME", "").strip()
37+ home = Path(configured).expanduser() if configured else Path.home() / ".msagent"
38+ return cls(home=home.resolve())
39+ 
40+ @classmethod
41+ def from_home(cls, home: Path) -> AppPaths:
42+ return cls(home=home.expanduser().resolve())
43+ 
44+ @property
45+ def config_dir(self) -> Path:
46+ return self.home / "config"
47+ 
48+ @property
49+ def metadata_file(self) -> Path:
50+ return self.home / "metadata.json"
51+ 
52+ @property
53+ def prompts_dir(self) -> Path:
54+ return self.home / "prompts"
55+ 
56+ @property
57+ def skills_dir(self) -> Path:
58+ return self.home / "skills"
59+ 
60+ @property
61+ def state_dir(self) -> Path:
62+ return self.home / "state"
63+ 
64+ @property
65+ def projects_dir(self) -> Path:
66+ return self.state_dir / "projects"
67+ 
68+ @property
69+ def mcp_cache_dir(self) -> Path:
70+ return self.home / "cache" / "mcp"
71+ 
72+ @property
73+ def sandbox_cache_dir(self) -> Path:
74+ return self.home / "cache" / "sandboxes"
75+ 
76+ @property
77+ def mcp_oauth_dir(self) -> Path:
78+ return self.home / "oauth" / "mcp"
79+ 
80+ @property
81+ def logs_dir(self) -> Path:
82+ return self.home / "logs"
83+ 
84+ def for_project(self, working_dir: Path) -> ProjectPaths:
85+ return ProjectPaths.resolve(self, working_dir)
86+ 
87+ def ensure(self) -> None:
88+ for path in (
89+ self.home,
90+ self.config_dir,
91+ self.prompts_dir,
92+ self.skills_dir,
93+ self.projects_dir,
94+ self.mcp_cache_dir,
95+ self.sandbox_cache_dir,
96+ self.mcp_oauth_dir,
97+ self.logs_dir,
98+ ):
99+ _ensure_private_dir(path)
100+ 
101+ 
102+@dataclass(frozen=True, slots=True)
103+class ProjectPaths:
104+ """Project-isolated runtime state stored below the global home."""
105+ 
106+ app: AppPaths
107+ working_dir: Path
108+ project_id: str
109+ 
110+ @classmethod
111+ def resolve(cls, app: AppPaths, working_dir: Path) -> ProjectPaths:
112+ canonical = working_dir.expanduser().resolve()
113+ identity = os.path.normcase(str(canonical)).encode("utf-8")
114+ digest = hashlib.sha256(identity).hexdigest()[:12]
115+ return cls(
116+ app=app,
117+ working_dir=canonical,
118+ project_id=f"{_project_slug(canonical)}-{digest}",
119+ )
120+ 
121+ @property
122+ def root(self) -> Path:
123+ return self.app.projects_dir / self.project_id
124+ 
125+ @property
126+ def metadata_file(self) -> Path:
127+ return self.root / "project.json"
128+ 
129+ @property
130+ def memory_file(self) -> Path:
131+ return self.root / "memory.md"
132+ 
133+ @property
134+ def history_file(self) -> Path:
135+ return self.root / "history"
136+ 
137+ @property
138+ def checkpoints_db(self) -> Path:
139+ return self.root / "checkpoints.sqlite"
140+ 
141+ @property
142+ def conversation_history_dir(self) -> Path:
143+ return self.root / "conversation_history"
144+ 
145+ @property
146+ def audit_dir(self) -> Path:
147+ return self.root / "audit_log"
148+ 
149+ def ensure(self) -> None:
150+ self.app.ensure()
151+ _ensure_private_dir(self.root)
152+ if self.metadata_file.exists():
153+ return
154+ 
155+ self._write_metadata(self._default_metadata())
156+ 
157+ def get_current_agent(self) -> str | None:
158+ """Return the Agent selected for this workspace, if one was persisted."""
159+ data = self._read_metadata()
160+ current_agent = data.get("current_agent")
161+ if not isinstance(current_agent, str):
162+ return None
163+ current_agent = current_agent.strip()
164+ return current_agent or None
165+ 
166+ def set_current_agent(self, agent_name: str) -> None:
167+ """Persist the Agent selected for this workspace."""
168+ normalized = agent_name.strip()
169+ if not normalized:
170+ raise ValueError("Agent name must not be empty")
171+ 
172+ self.app.ensure()
173+ _ensure_private_dir(self.root)
174+ payload = self._read_metadata()
175+ payload.update(self._default_metadata())
176+ payload["current_agent"] = normalized
177+ self._write_metadata(payload)
178+ 
179+ def get_current_model(self, agent_name: str) -> str | None:
180+ """Return the workspace model preference for an Agent, if present."""
181+ models = self._read_metadata().get("agent_models")
182+ if not isinstance(models, dict):
183+ return None
184+ model_name = models.get(agent_name)
185+ if not isinstance(model_name, str):
186+ return None
187+ model_name = model_name.strip()
188+ return model_name or None
189+ 
190+ def set_current_model(self, agent_name: str, model_name: str) -> None:
191+ """Persist a workspace model preference without changing Agent definitions."""
192+ normalized_agent = agent_name.strip()
193+ normalized_model = model_name.strip()
194+ if not normalized_agent or not normalized_model:
195+ raise ValueError("Agent and model names must not be empty")
196+ 
197+ self.app.ensure()
198+ _ensure_private_dir(self.root)
199+ payload = self._read_metadata()
200+ payload.update(self._default_metadata())
201+ existing_models = payload.get("agent_models")
202+ models = dict(existing_models) if isinstance(existing_models, dict) else {}
203+ models[normalized_agent] = normalized_model
204+ payload["agent_models"] = models
205+ self._write_metadata(payload)
206+ 
207+ def _default_metadata(self) -> dict[str, str]:
208+ return {
209+ "project_id": self.project_id,
210+ "working_dir": str(self.working_dir),
211+ }
212+ 
213+ def _read_metadata(self) -> dict[str, object]:
214+ if not self.metadata_file.is_file():
215+ return {}
216+ try:
217+ payload = json.loads(self.metadata_file.read_text(encoding="utf-8"))
218+ except (OSError, json.JSONDecodeError):
219+ return {}
220+ return payload if isinstance(payload, dict) else {}
221+ 
222+ def _write_metadata(self, payload: Mapping[str, object]) -> None:
223+ temp_file = self.metadata_file.with_name(f".{self.metadata_file.name}.{os.getpid()}.{id(self)}.tmp")
224+ try:
225+ temp_file.write_text(
226+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
227+ encoding="utf-8",
228+ )
229+ temp_file.replace(self.metadata_file)
230+ finally:
231+ if temp_file.exists():
232+ temp_file.unlink()
@@ -0,0 +1,224 @@
1+"""Validation and initialization for the global msAgent storage layout."""
2+ 
3+from __future__ import annotations
4+ 
5+import json
6+import os
7+import uuid
8+from pathlib import Path
9+ 
10+from msagent.core.paths import AppPaths
11+ 
12+STORAGE_LAYOUT_VERSION = 2
13+ 
14+LEGACY_HOME_ENTRIES = (
15+ "config.llms.yml",
16+ "config.mcp.json",
17+ "config.approval.json",
18+ "config.agents.yml",
19+ "config.subagents.yml",
20+ "config.checkpointers.yml",
21+ "config.checkpoints.db",
22+ "langgraph.json",
23+ ".history",
24+ "memory.md",
25+ "agents/",
26+ "subagents/",
27+ "llms/",
28+ "checkpointers/",
29+ "sandboxes/",
30+)
31+ 
32+_MANAGED_DIRECTORIES = (
33+ "config",
34+ "prompts",
35+ "skills",
36+ "state",
37+ "state/projects",
38+ "cache",
39+ "cache/mcp",
40+ "cache/sandboxes",
41+ "oauth",
42+ "oauth/mcp",
43+ "logs",
44+)
45+ 
46+ 
47+class StorageLayoutError(RuntimeError):
48+ """Raised when the global storage directory cannot be used safely."""
49+ 
50+ 
51+def validate_and_initialize_storage_layout(paths: AppPaths) -> None:
52+ """Validate the configured home and initialize storage layout version 2."""
53+ _inspect_storage_layout(paths)
54+ # Inspect again immediately before the first write to narrow the race with
55+ # another process or an external tool changing the directory.
56+ metadata_version = _inspect_storage_layout(paths)
57+ _ensure_layout_directories(paths)
58+ if metadata_version is not None:
59+ return
60+ 
61+ if _path_present(paths.metadata_file):
62+ _read_metadata_version(paths.metadata_file, {paths.metadata_file.name})
63+ return
64+ _write_metadata_atomically(paths.metadata_file)
65+ 
66+ 
67+def _inspect_storage_layout(paths: AppPaths) -> int | None:
68+ entries = _read_home_entries(paths.home)
69+ metadata_version = _read_metadata_version(paths.metadata_file, entries)
70+ legacy_entries = _find_legacy_entries(entries)
71+ new_entries = _find_new_entries(paths, entries, metadata_version)
72+ if legacy_entries:
73+ if new_entries:
74+ raise StorageLayoutError(_mixed_layout_message(paths.home, legacy_entries))
75+ raise StorageLayoutError(_legacy_layout_message(paths.home))
76+ 
77+ _validate_managed_directories(paths.home)
78+ return metadata_version
79+ 
80+ 
81+def _ensure_layout_directories(paths: AppPaths) -> None:
82+ try:
83+ paths.ensure()
84+ except OSError as exc:
85+ raise StorageLayoutError(f"Cannot initialize msAgent storage at {paths.home}: {exc}") from exc
86+ 
87+ 
88+def _read_home_entries(home: Path) -> set[str]:
89+ if not _path_present(home):
90+ return set()
91+ if home.is_symlink() or not home.is_dir():
92+ raise StorageLayoutError(f"msAgent home must be a directory: {home}")
93+ try:
94+ return {entry.name for entry in home.iterdir()}
95+ except OSError as exc:
96+ raise StorageLayoutError(f"Cannot inspect msAgent home {home}: {exc}") from exc
97+ 
98+ 
99+def _read_metadata_version(metadata_file: Path, entries: set[str]) -> int | None:
100+ if metadata_file.name not in entries:
101+ return None
102+ if metadata_file.is_symlink() or not metadata_file.is_file():
103+ raise StorageLayoutError(f"msAgent storage metadata must be a regular file: {metadata_file}")
104+ try:
105+ payload = json.loads(metadata_file.read_text(encoding="utf-8"))
106+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
107+ raise StorageLayoutError(
108+ f"Cannot read valid msAgent storage metadata from {metadata_file}: {exc}. "
109+ "Back up the file and repair or remove the storage directory."
110+ ) from exc
111+ if not isinstance(payload, dict):
112+ raise StorageLayoutError(f"msAgent storage metadata must contain a JSON object: {metadata_file}")
113+ 
114+ version = payload.get("storage_layout_version")
115+ if not isinstance(version, int) or isinstance(version, bool) or version <= 0:
116+ raise StorageLayoutError(f"Invalid storage_layout_version in {metadata_file}; expected a positive integer.")
117+ if version < STORAGE_LAYOUT_VERSION:
118+ raise StorageLayoutError(
119+ f"Storage layout version {version} at {metadata_file.parent} is no longer supported. "
120+ "Back up and move the directory, then run msAgent again."
121+ )
122+ if version > STORAGE_LAYOUT_VERSION:
123+ raise StorageLayoutError(
124+ f"Storage layout version {version} at {metadata_file.parent} is newer than this msAgent "
125+ "version supports. Upgrade msAgent before using this directory."
126+ )
127+ return version
128+ 
129+ 
130+def _find_legacy_entries(entries: set[str]) -> tuple[str, ...]:
131+ return tuple(label for label in LEGACY_HOME_ENTRIES if label.rstrip("/") in entries)
132+ 
133+ 
134+def _find_new_entries(paths: AppPaths, entries: set[str], metadata_version: int | None) -> tuple[str, ...]:
135+ found: list[str] = []
136+ if metadata_version == STORAGE_LAYOUT_VERSION:
137+ found.append("metadata.json")
138+ if "config" in entries and paths.config_dir.is_dir():
139+ found.append("config/")
140+ if paths.projects_dir.is_dir():
141+ found.append("state/projects/")
142+ return tuple(found)
143+ 
144+ 
145+def _validate_managed_directories(home: Path) -> None:
146+ for relative_path in _MANAGED_DIRECTORIES:
147+ path = home / relative_path
148+ if not _path_present(path):
149+ continue
150+ if path.is_symlink() or not path.is_dir():
151+ raise StorageLayoutError(f"msAgent storage path must be a regular directory: {path}")
152+ 
153+ 
154+def _write_metadata_atomically(metadata_file: Path) -> None:
155+ temp_file = metadata_file.with_name(f".{metadata_file.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
156+ payload = {"storage_layout_version": STORAGE_LAYOUT_VERSION}
157+ try:
158+ descriptor = os.open(temp_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
159+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
160+ json.dump(payload, stream, ensure_ascii=True, indent=2)
161+ stream.write("\n")
162+ stream.flush()
163+ os.fsync(stream.fileno())
164+ temp_file.replace(metadata_file)
165+ except OSError as exc:
166+ raise StorageLayoutError(f"Cannot write msAgent storage metadata {metadata_file}: {exc}") from exc
167+ finally:
168+ try:
169+ temp_file.unlink(missing_ok=True)
170+ except OSError:
171+ pass
172+ 
173+ 
174+def _path_present(path: Path) -> bool:
175+ return path.exists() or path.is_symlink()
176+ 
177+ 
178+def _legacy_layout_message(home: Path) -> str:
179+ return (
180+ "msAgent 启动已中止:检测到不兼容的旧版配置目录。\n\n"
181+ "您已升级 msAgent,但以下目录仍为旧版本结构:\n\n"
182+ f" {home}\n\n"
183+ "新版改为统一使用全局目录 ~/.msagent,配置只需设置一次,即可在多个项目间共享;"
184+ "同时各工作目录的运行状态独立保存,互不干扰。\n\n"
185+ "由于新旧目录结构不兼容,无法直接复用,请手动删除该目录后重新启动 msAgent。"
186+ "启动时会自动创建新版目录结构。\n\n"
187+ "⚠️ 删除前请注意:\n"
188+ f" • 将清除:{home} 下的所有配置、状态、历史及检查点\n"
189+ f" • 如需保留数据,请提前备份\n"
190+ f" • {home}/skills 可能包含您自己添加的 Skill,请单独备份\n"
191+ f" • 新版内置 Skill 由安装包提供,无需备份或恢复\n\n"
192+ "本次启动未对该目录做任何更改。"
193+ )
194+ 
195+ 
196+def _mixed_layout_message(
197+ home: Path,
198+ legacy_entries: tuple[str, ...],
199+) -> str:
200+ legacy_summary = ", ".join(legacy_entries)
201+ skills_note = _skills_backup_note(home)
202+ 
203+ return (
204+ "msAgent 启动已中止:检测到新旧版本配置混用。\n\n"
205+ "您已升级 msAgent,但以下目录中同时存在新版结构和旧版残留:\n\n"
206+ f" {home}\n\n"
207+ "检测到的旧版残留:\n\n"
208+ f" {legacy_summary}\n\n"
209+ "新版改为统一使用全局目录 ~/.msagent,配置只需设置一次,即可在多个项目间共享;"
210+ "同时各工作目录的运行状态独立保存,互不干扰。\n\n"
211+ "由于新旧目录结构不兼容,无法安全混用,请手动删除该目录后重新启动 msAgent。"
212+ "启动时会自动创建新版目录结构。\n\n"
213+ "⚠️ 删除前请注意:\n"
214+ f" • 会清除:新版配置、项目状态、对话历史、检查点\n"
215+ f" • 如需保留数据,请提前备份\n"
216+ f"{skills_note}\n"
217+ "本次启动未对该目录做任何更改。"
218+ )
219+ 
220+ 
221+def _skills_backup_note(home: Path) -> str:
222+ return (
223+ f" • {home}/skills 可能包含您自己添加的 Skill,请单独备份\n • 新版内置 Skill 由安装包提供,无需备份或恢复\n"
224+ )
@@ -11,8 +11,6 @@ import shutil
11from dataclasses import dataclass, field11from dataclasses import dataclass, field
12from pathlib import Path12from pathlib import Path
13 13 
14-from msagent.cli.bootstrap.initializer import initializer
15-from msagent.core.constants import CONFIG_SKILLS_DIR
16from msagent.skills.factory import DEFAULT_SKILL_CATEGORY, SkillFactory14from msagent.skills.factory import DEFAULT_SKILL_CATEGORY, SkillFactory
17 15 
18SKILL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")16SKILL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
@@ -45,8 +43,9 @@ class SkillInstallResult:
45class SkillInstaller:43class SkillInstaller:
46 """Validate and install custom skills into the local config directory."""44 """Validate and install custom skills into the local config directory."""
47 45 
48- def __init__(self, working_dir: Path) -> None:46+ def __init__(self, working_dir: Path, skills_dir: Path | None = None) -> None:
49 self.working_dir = working_dir47 self.working_dir = working_dir
48+ self.skills_dir = skills_dir or working_dir / ".msagent" / "skills"
50 self.skill_factory = SkillFactory()49 self.skill_factory = SkillFactory()
51 50 
52 async def install(self, raw_path: str) -> SkillInstallResult:51 async def install(self, raw_path: str) -> SkillInstallResult:
@@ -76,7 +75,7 @@ class SkillInstaller:
76 category = DEFAULT_SKILL_CATEGORY75 category = DEFAULT_SKILL_CATEGORY
77 pattern = f"{category}:{name}"76 pattern = f"{category}:{name}"
78 target_dir_name = self._build_target_dir_name(name)77 target_dir_name = self._build_target_dir_name(name)
79- target_root = self.working_dir / CONFIG_SKILLS_DIR / target_dir_name78+ target_root = self.skills_dir / target_dir_name
80 target_skill_file = target_root / "SKILL.md"79 target_skill_file = target_root / "SKILL.md"
81 80 
82 if target_root.exists():81 if target_root.exists():
@@ -133,8 +132,12 @@ class SkillInstaller:
133 return nested_skill_files[0].parent, nested_skill_files[0]132 return nested_skill_files[0].parent, nested_skill_files[0]
134 133 
135 async def _ensure_not_shadowed(self, pattern: str) -> None:134 async def _ensure_not_shadowed(self, pattern: str) -> None:
136- skills_dirs = initializer.resolve_skills_dirs(self.working_dir)135+ skills_dirs = [
137- config_dir = self.working_dir / CONFIG_SKILLS_DIR136+ self.working_dir / "skills",
137+ self.skills_dir,
138+ self.skill_factory.get_default_skills_dir(),
139+ ]
140+ config_dir = self.skills_dir
138 141 
139 shadowing_dirs: list[Path] = []142 shadowing_dirs: list[Path] = []
140 for skills_dir in skills_dirs:143 for skills_dir in skills_dirs:
@@ -28,6 +28,7 @@ from typing import Any
28from langchain_core.tools import ToolException, tool28from langchain_core.tools import ToolException, tool
29from pydantic import BaseModel, Field29from pydantic import BaseModel, Field
30 30 
31+from msagent.core.paths import AppPaths
31from msagent.skills.factory import (32from msagent.skills.factory import (
32 DEFAULT_SKILL_CATEGORY,33 DEFAULT_SKILL_CATEGORY,
33 SkillFactory,34 SkillFactory,
@@ -65,8 +66,8 @@ async def _fallback_skill_catalog() -> list[Any]:
65 working_dir = Path.cwd()66 working_dir = Path.cwd()
66 candidates = [67 candidates = [
67 working_dir / "skills",68 working_dir / "skills",
69+ AppPaths.resolve().skills_dir,
68 default_dir,70 default_dir,
69- working_dir / ".msagent" / "skills",
70 ]71 ]
71 unique: list[Path] = []72 unique: list[Path] = []
72 seen: set[str] = set()73 seen: set[str] = set()
@@ -22,8 +22,6 @@ from __future__ import annotations
22 22 
23from pathlib import Path23from pathlib import Path
24 24 
25-from msagent.core.constants import CONFIG_MEMORY_FILE_NAME
26- 
27DEFAULT_MEMORY_FILE_CONTENT = """# msagent memory25DEFAULT_MEMORY_FILE_CONTENT = """# msagent memory
28 26 
29Store durable user preferences and project facts here.27Store durable user preferences and project facts here.
@@ -33,17 +31,25 @@ Store durable user preferences and project facts here.
33_DEFAULT_EMPTY_BULLET = "-\n"31_DEFAULT_EMPTY_BULLET = "-\n"
34 32 
35 33 
36-def get_memory_file_path(working_dir: Path | None = None) -> Path:34+def get_memory_file_path(
35+ working_dir: Path | None = None,
36+ *,
37+ state_dir: Path | None = None,
38+) -> Path:
37 """Return the canonical memory file path used by deepagents memory middleware."""39 """Return the canonical memory file path used by deepagents memory middleware."""
38- if working_dir is None:40+ if state_dir is None:
39- working_dir = Path.cwd()41+ state_dir = (working_dir or Path.cwd()) / ".msagent"
40 42 
41- return working_dir / CONFIG_MEMORY_FILE_NAME43+ return state_dir / "memory.md"
42 44 
43 45 
44-def ensure_memory_file(working_dir: Path | None = None) -> Path:46+def ensure_memory_file(
47+ working_dir: Path | None = None,
48+ *,
49+ state_dir: Path | None = None,
50+) -> Path:
45 """Ensure the memory file exists and return its path."""51 """Ensure the memory file exists and return its path."""
46- memory_path = get_memory_file_path(working_dir)52+ memory_path = get_memory_file_path(working_dir, state_dir=state_dir)
47 memory_path.parent.mkdir(parents=True, exist_ok=True)53 memory_path.parent.mkdir(parents=True, exist_ok=True)
48 if not memory_path.exists():54 if not memory_path.exists():
49 memory_path.write_text(DEFAULT_MEMORY_FILE_CONTENT, encoding="utf-8")55 memory_path.write_text(DEFAULT_MEMORY_FILE_CONTENT, encoding="utf-8")
@@ -55,13 +61,18 @@ def is_default_memory_content(content: str) -> bool:
55 return content.strip() == DEFAULT_MEMORY_FILE_CONTENT.strip()61 return content.strip() == DEFAULT_MEMORY_FILE_CONTENT.strip()
56 62 
57 63 
58-def append_memory_entry(content: str, working_dir: Path | None = None) -> Path:64+def append_memory_entry(
65+ content: str,
66+ working_dir: Path | None = None,
67+ *,
68+ state_dir: Path | None = None,
69+) -> Path:
59 """Append a durable memory entry to the canonical memory file."""70 """Append a durable memory entry to the canonical memory file."""
60 entry = content.strip()71 entry = content.strip()
61 if not entry:72 if not entry:
62 raise ValueError("Memory content cannot be empty")73 raise ValueError("Memory content cannot be empty")
63 74 
64- memory_path = ensure_memory_file(working_dir)75+ memory_path = ensure_memory_file(working_dir, state_dir=state_dir)
65 try:76 try:
66 current = memory_path.read_text(encoding="utf-8")77 current = memory_path.read_text(encoding="utf-8")
67 except Exception:78 except Exception:
@@ -77,9 +88,13 @@ def append_memory_entry(content: str, working_dir: Path | None = None) -> Path:
77 return memory_path88 return memory_path
78 89 
79 90 
80-def read_memory_file(working_dir: Path | None = None) -> str:91+def read_memory_file(
92+ working_dir: Path | None = None,
93+ *,
94+ state_dir: Path | None = None,
95+) -> str:
81 """Read user memory content from the canonical memory file."""96 """Read user memory content from the canonical memory file."""
82- memory_path = get_memory_file_path(working_dir)97+ memory_path = get_memory_file_path(working_dir, state_dir=state_dir)
83 98 
84 if not memory_path.exists():99 if not memory_path.exists():
85 return ""100 return ""
@@ -77,16 +77,88 @@ def _read_jsonl(path: Path) -> list[dict]:
77def test_entrypoint_version_and_config_show(tmp_path: Path) -> None:77def test_entrypoint_version_and_config_show(tmp_path: Path) -> None:
78 version = _run_msagent("--version", cwd=PROJECT_ROOT)78 version = _run_msagent("--version", cwd=PROJECT_ROOT)
79 assert version.returncode == 079 assert version.returncode == 0
80- assert "msAgent" in version.stdout80+ assert "msagent" in _normalize_terminal_output(version.stdout).lower()
81 81 
82- config = _run_msagent("config", "--show", "-w", str(tmp_path), cwd=PROJECT_ROOT)82+ config = _run_msagent(
83+ "config",
84+ "--show",
85+ "-w",
86+ str(tmp_path),
87+ cwd=PROJECT_ROOT,
88+ env={"MSAGENT_HOME": str(tmp_path / "global-home")},
89+ )
83 assert config.returncode == 090 assert config.returncode == 0
84 assert "Current Configuration" in config.stdout91 assert "Current Configuration" in config.stdout
85 assert "LLM Provider" in config.stdout92 assert "LLM Provider" in config.stdout
93+ assert not (tmp_path / ".msagent").exists()
94+ assert (tmp_path / "global-home" / "state" / "projects").is_dir()
95+ 
96+ updated = _run_msagent(
97+ "config",
98+ "--llm-model",
99+ "smoke-model",
100+ "-w",
101+ str(tmp_path),
102+ cwd=PROJECT_ROOT,
103+ env={"MSAGENT_HOME": str(tmp_path / "global-home")},
104+ )
105+ assert updated.returncode == 0
106+ assert (tmp_path / "global-home" / "config" / "config.llms.yml").is_file()
107+ assert not (tmp_path / "global-home" / "config" / "agents").exists()
108+ 
109+ 
110+def test_entrypoint_blocks_legacy_global_home_but_allows_help(tmp_path: Path) -> None:
111+ # This also models running from the user's home, where workspace/.msagent
112+ # and the default global directory are the same path.
113+ home = tmp_path / ".msagent"
114+ home.mkdir()
115+ legacy_file = home / "config.llms.yml"
116+ legacy_file.write_text("legacy", encoding="utf-8")
117+ env = {"MSAGENT_HOME": str(home)}
118+ 
119+ blocked = _run_msagent("config", "--show", cwd=tmp_path, env=env)
120+ output = _normalize_terminal_output(blocked.stdout + blocked.stderr)
121+ assert blocked.returncode != 0
122+ assert str(home.resolve()) in output
123+ assert "msAgent 启动已中止:检测到不兼容的旧版配置目录" in output
124+ assert "新版改为统一使用全局目录" in output
125+ assert "配置只需设置一次" in output
126+ assert "请手动删除该目录后重新启动" in output
127+ assert "config.llms.yml" not in output
128+ assert not (home / "metadata.json").exists()
129+ assert not (home / "logs").exists()
130+ 
131+ help_result = _run_msagent("--help", cwd=tmp_path, env=env)
132+ assert help_result.returncode == 0
133+ assert legacy_file.read_text(encoding="utf-8") == "legacy"
134+ 
135+ 
136+def test_workspace_legacy_residue_does_not_block_global_layout(tmp_path: Path) -> None:
137+ workspace = tmp_path / "workspace"
138+ residue = workspace / ".msagent" / "config.llms.yml"
139+ residue.parent.mkdir(parents=True)
140+ residue.write_text("legacy workspace config", encoding="utf-8")
141+ home = tmp_path / "global-home"
142+ 
143+ result = _run_msagent(
144+ "config",
145+ "--show",
146+ "-w",
147+ str(workspace),
148+ cwd=workspace,
149+ env={"MSAGENT_HOME": str(home)},
150+ )
151+ 
152+ assert result.returncode == 0
153+ assert (home / "metadata.json").is_file()
154+ assert residue.read_text(encoding="utf-8") == "legacy workspace config"
86 155 
87 156 
88def test_entrypoint_one_shot_tool_call_and_todo_render(tmp_path: Path) -> None:157def test_entrypoint_one_shot_tool_call_and_todo_render(tmp_path: Path) -> None:
89- env = {"MSAGENT_FAKE_BACKEND": "1"}158+ env = {
159+ "MSAGENT_FAKE_BACKEND": "1",
160+ "MSAGENT_HOME": str(tmp_path / "global-home"),
161+ }
90 162 
91 tool = _run_msagent(163 tool = _run_msagent(
92 "-w",164 "-w",
@@ -123,7 +195,10 @@ def test_entrypoint_trace_jsonl_records_tools_tokens_and_time(tmp_path: Path) ->
123 str(tmp_path),195 str(tmp_path),
124 "please run one tool call",196 "please run one tool call",
125 cwd=PROJECT_ROOT,197 cwd=PROJECT_ROOT,
126- env={"MSAGENT_FAKE_BACKEND": "1"},198+ env={
199+ "MSAGENT_FAKE_BACKEND": "1",
200+ "MSAGENT_HOME": str(tmp_path / "global-home"),
201+ },
127 )202 )
128 203 
129 assert result.returncode == 0204 assert result.returncode == 0
@@ -28,6 +28,7 @@ from msagent.cli.bootstrap.legacy import (
28 render_version_info,28 render_version_info,
29)29)
30from msagent.core.constants import APP_NAME30from msagent.core.constants import APP_NAME
31+from msagent.core.storage_layout import StorageLayoutError
31 32 
32 33 
33def test_create_session_parser_defaults_to_interactive_mode() -> None:34def test_create_session_parser_defaults_to_interactive_mode() -> None:
@@ -146,6 +147,44 @@ async def test_main_short_circuits_root_version(monkeypatch: pytest.MonkeyPatch)
146 assert called == {"help": 0, "version": 1}147 assert called == {"help": 0, "version": 1}
147 148 
148 149 
150+@pytest.mark.asyncio
151+@pytest.mark.parametrize("argv", [["--help"], ["-h"], ["config", "--help"], ["config", "-h"], ["--version"], ["-V"]])
152+async def test_help_and_version_bypass_storage_guard(
153+ monkeypatch: pytest.MonkeyPatch,
154+ argv: list[str],
155+) -> None:
156+ monkeypatch.setattr(bootstrap_app.sys, "argv", ["msagent", *argv])
157+ monkeypatch.setattr(bootstrap_app, "render_root_help", lambda: None)
158+ monkeypatch.setattr(bootstrap_app, "render_config_help", lambda: None)
159+ monkeypatch.setattr(bootstrap_app, "render_version_info", lambda: None)
160+ monkeypatch.setattr(
161+ bootstrap_app,
162+ "validate_and_initialize_storage_layout",
163+ lambda _paths: pytest.fail("storage guard should be bypassed"),
164+ )
165+ 
166+ assert await bootstrap_app.main() == 0
167+ 
168+ 
169+@pytest.mark.asyncio
170+async def test_storage_guard_runs_before_logging_and_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:
171+ events: list[str] = []
172+ errors: list[str] = []
173+ 
174+ def reject_layout(_paths) -> None:
175+ events.append("guard")
176+ raise StorageLayoutError("legacy layout")
177+ 
178+ monkeypatch.setattr(bootstrap_app.sys, "argv", ["msagent", "config", "--show"])
179+ monkeypatch.setattr(bootstrap_app, "validate_and_initialize_storage_layout", reject_layout)
180+ monkeypatch.setattr(bootstrap_app, "configure_logging", lambda **_kwargs: events.append("logging"))
181+ monkeypatch.setattr(bootstrap_app.console, "print_error", errors.append)
182+ 
183+ assert await bootstrap_app.main() == 1
184+ assert events == ["guard"]
185+ assert errors == ["legacy layout"]
186+ 
187+ 
149def test_render_version_info_formats_version_payload(monkeypatch: pytest.MonkeyPatch) -> None:188def test_render_version_info_formats_version_payload(monkeypatch: pytest.MonkeyPatch) -> None:
150 printed: list[str] = []189 printed: list[str] = []
151 190 
@@ -27,6 +27,7 @@ import pytest
27from msagent.agents.context import AgentContext27from msagent.agents.context import AgentContext
28from msagent.cli.bootstrap.initializer import Initializer28from msagent.cli.bootstrap.initializer import Initializer
29from msagent.configs import ToolApprovalConfig29from msagent.configs import ToolApprovalConfig
30+from msagent.core.paths import AppPaths
30from msagent.skills.factory import Skill31from msagent.skills.factory import Skill
31 32 
32 33 
@@ -73,7 +74,8 @@ async def test_initializer_passes_agent_context_schema_to_agent_factory(
73 tmp_path: Path,74 tmp_path: Path,
74 monkeypatch,75 monkeypatch,
75) -> None:76) -> None:
76- init = Initializer()77+ app_paths = AppPaths.from_home(tmp_path / ".msagent-home")
78+ init = Initializer(app_paths)
77 79 
78 agent_config = SimpleNamespace(80 agent_config = SimpleNamespace(
79 checkpointer=None,81 checkpointer=None,
@@ -118,6 +120,7 @@ async def test_initializer_passes_agent_context_schema_to_agent_factory(
118 )120 )
119 121 
120 assert create_mock.await_args.kwargs["context_schema"] is AgentContext122 assert create_mock.await_args.kwargs["context_schema"] is AgentContext
123+ assert create_mock.await_args.kwargs["project_state_dir"] == app_paths.for_project(tmp_path).root
121 assert create_mock.await_args.kwargs["skills_dir"] is None124 assert create_mock.await_args.kwargs["skills_dir"] is None
122 assert create_mock.await_args.kwargs["interrupt_on"] == {"execute": {"allowed_decisions": ["approve", "reject"]}}125 assert create_mock.await_args.kwargs["interrupt_on"] == {"execute": {"allowed_decisions": ["approve", "reject"]}}
123 await cleanup()126 await cleanup()
@@ -21,7 +21,7 @@ from pathlib import Path
21import pytest21import pytest
22 22 
23from msagent.cli.bootstrap.initializer import Initializer23from msagent.cli.bootstrap.initializer import Initializer
24-from msagent.core.constants import CONFIG_SKILLS_DIR24+from msagent.core.paths import AppPaths
25from msagent.skills.factory import BASIC_SKILL_CATEGORY, DEFAULT_SKILL_CATEGORY, SkillFactory25from msagent.skills.factory import BASIC_SKILL_CATEGORY, DEFAULT_SKILL_CATEGORY, SkillFactory
26 26 
27 27 
@@ -59,7 +59,8 @@ def test_default_explorer_subagent_prompt_has_scope_constraints() -> None:
59@pytest.mark.asyncio59@pytest.mark.asyncio
60async def test_skill_factory_loads_workspace_and_config_skills(tmp_path: Path) -> None:60async def test_skill_factory_loads_workspace_and_config_skills(tmp_path: Path) -> None:
61 workspace_skills = tmp_path / "skills" / "analysis" / "workspace-skill"61 workspace_skills = tmp_path / "skills" / "analysis" / "workspace-skill"
62- config_skills = tmp_path / ".msagent" / "skills" / "analysis" / "config-skill"62+ app_paths = AppPaths.from_home(tmp_path / "global-home" / ".msagent")
63+ config_skills = app_paths.skills_dir / "analysis" / "config-skill"
63 workspace_skills.mkdir(parents=True)64 workspace_skills.mkdir(parents=True)
64 config_skills.mkdir(parents=True)65 config_skills.mkdir(parents=True)
65 66 
@@ -72,7 +73,7 @@ content
72 (workspace_skills / "SKILL.md").write_text(skill_text.format(name="workspace-skill"), encoding="utf-8")73 (workspace_skills / "SKILL.md").write_text(skill_text.format(name="workspace-skill"), encoding="utf-8")
73 (config_skills / "SKILL.md").write_text(skill_text.format(name="config-skill"), encoding="utf-8")74 (config_skills / "SKILL.md").write_text(skill_text.format(name="config-skill"), encoding="utf-8")
74 75 
75- skills = await SkillFactory().load_skills([tmp_path / "skills", tmp_path / ".msagent" / "skills"])76+ skills = await SkillFactory().load_skills([tmp_path / "skills", app_paths.skills_dir])
76 77 
77 assert "analysis" in skills78 assert "analysis" in skills
78 assert "workspace-skill" in skills["analysis"]79 assert "workspace-skill" in skills["analysis"]
@@ -122,7 +123,8 @@ content
122 123 
123 124 
124def test_initializer_resolves_default_skill_search_order(tmp_path: Path) -> None:125def test_initializer_resolves_default_skill_search_order(tmp_path: Path) -> None:
125- init = Initializer()126+ app_paths = AppPaths.from_home(tmp_path / "global-home" / ".msagent")
127+ init = Initializer(app_paths)
126 default_skills = tmp_path / "bundled-skills"128 default_skills = tmp_path / "bundled-skills"
127 129 
128 init.skill_factory.get_default_skills_dir = lambda: default_skills130 init.skill_factory.get_default_skills_dir = lambda: default_skills
@@ -131,8 +133,8 @@ def test_initializer_resolves_default_skill_search_order(tmp_path: Path) -> None
131 133 
132 assert skill_dirs == [134 assert skill_dirs == [
133 tmp_path / "skills",135 tmp_path / "skills",
136+ app_paths.skills_dir,
134 default_skills,137 default_skills,
135- tmp_path / CONFIG_SKILLS_DIR,
136 ]138 ]
137 139 
138 140 
@@ -48,7 +48,11 @@ async def test_context_create_keeps_alias_and_exposes_resolved_model(
48 async def fake_load_agent_config(agent, working_dir):48 async def fake_load_agent_config(agent, working_dir):
49 return agent_config49 return agent_config
50 50 
51+ async def fake_get_current_model(agent, working_dir):
52+ return None
53+ 
51 monkeypatch.setattr(initializer, "load_agent_config", fake_load_agent_config)54 monkeypatch.setattr(initializer, "load_agent_config", fake_load_agent_config)
55+ monkeypatch.setattr(initializer, "get_current_model", fake_get_current_model)
52 56 
53 context = await Context.create(57 context = await Context.create(
54 agent=None,58 agent=None,
@@ -62,6 +66,54 @@ async def test_context_create_keeps_alias_and_exposes_resolved_model(
62 assert context.model_display == "deepseek-chat (openai)"66 assert context.model_display == "deepseek-chat (openai)"
63 67 
64 68 
69+@pytest.mark.asyncio
70+async def test_context_create_uses_workspace_model_preference(monkeypatch) -> None:
71+ default_llm = SimpleNamespace(
72+ alias="default",
73+ model="default-model",
74+ provider=LLMProvider.OPENAI,
75+ context_window=64000,
76+ )
77+ preferred_llm = SimpleNamespace(
78+ alias="fast",
79+ model="fast-model",
80+ provider=LLMProvider.OPENAI,
81+ context_window=128000,
82+ )
83+ agent_config = SimpleNamespace(
84+ name="Profiler",
85+ description="Profiler",
86+ llm=default_llm,
87+ tools=None,
88+ recursion_limit=80,
89+ )
90+ 
91+ async def fake_load_agent_config(_agent, _working_dir):
92+ return agent_config
93+ 
94+ async def fake_get_current_model(_agent, _working_dir):
95+ return "fast"
96+ 
97+ async def fake_load_llm_config(model, _working_dir):
98+ assert model == "fast"
99+ return preferred_llm
100+ 
101+ monkeypatch.setattr(initializer, "load_agent_config", fake_load_agent_config)
102+ monkeypatch.setattr(initializer, "get_current_model", fake_get_current_model)
103+ monkeypatch.setattr(initializer, "load_llm_config", fake_load_llm_config)
104+ 
105+ context = await Context.create(
106+ agent=None,
107+ model=None,
108+ approval_mode=ApprovalMode.SEMI_ACTIVE,
109+ working_dir=Path.cwd(),
110+ )
111+ 
112+ assert context.agent == "Profiler"
113+ assert context.model == "fast"
114+ assert context.model_display == "fast-model (openai)"
115+ 
116+ 
65def test_agent_context_defaults_to_cwd() -> None:117def test_agent_context_defaults_to_cwd() -> None:
66 context = AgentContext()118 context = AgentContext()
67 119 
@@ -8,6 +8,8 @@ import yaml
8from msagent.cli.dispatchers.commands import CommandDispatcher8from msagent.cli.dispatchers.commands import CommandDispatcher
9from msagent.cli.handlers import add_skill as add_skill_module9from msagent.cli.handlers import add_skill as add_skill_module
10from msagent.cli.handlers.add_skill import AddSkillHandler10from msagent.cli.handlers.add_skill import AddSkillHandler
11+from msagent.cli.bootstrap.initializer import Initializer
12+from msagent.core.paths import AppPaths
11from msagent.skills.installer import SkillInstallError, SkillInstaller13from msagent.skills.installer import SkillInstallError, SkillInstaller
12 14 
13 15 
@@ -51,6 +53,8 @@ async def test_add_skill_handler_installs_skill_and_updates_agent_patterns(
51 session = _build_session(tmp_path)53 session = _build_session(tmp_path)
52 handler = AddSkillHandler(session)54 handler = AddSkillHandler(session)
53 messages: list[str] = []55 messages: list[str] = []
56+ app_paths = AppPaths.from_home(tmp_path / "global-home" / ".msagent")
57+ monkeypatch.setattr(add_skill_module, "initializer", Initializer(app_paths))
54 58 
55 monkeypatch.setattr(add_skill_module.console, "print_success", messages.append)59 monkeypatch.setattr(add_skill_module.console, "print_success", messages.append)
56 monkeypatch.setattr(add_skill_module.console, "print_error", messages.append)60 monkeypatch.setattr(add_skill_module.console, "print_error", messages.append)
@@ -58,10 +62,10 @@ async def test_add_skill_handler_installs_skill_and_updates_agent_patterns(
58 62 
59 await handler.handle([str(skill_dir)])63 await handler.handle([str(skill_dir)])
60 64 
61- installed_skill = tmp_path / ".msagent" / "skills" / "custom_skill" / "SKILL.md"65+ installed_skill = app_paths.skills_dir / "custom_skill" / "SKILL.md"
62 assert installed_skill.exists()66 assert installed_skill.exists()
63 67 
64- profiler_config = yaml.safe_load((tmp_path / ".msagent" / "agents" / "Profiler.yml").read_text(encoding="utf-8"))68+ profiler_config = yaml.safe_load((app_paths.config_dir / "agents" / "Profiler.yml").read_text(encoding="utf-8"))
65 assert "default:custom_skill" in profiler_config["skills"]["patterns"]69 assert "default:custom_skill" in profiler_config["skills"]["patterns"]
66 assert session.needs_reload is True70 assert session.needs_reload is True
67 assert session.running is False71 assert session.running is False
@@ -176,15 +176,21 @@ async def test_agents_handler_updates_context_when_different_agent_selected(
176 return minos176 return minos
177 return hermes177 return hermes
178 178 
179- async def fake_update_default_agent(_agent_name, _working_dir):179+ persisted_agents: list[str] = []
180- pass180+ 
181+ async def fake_get_current_model(_agent_name, _working_dir):
182+ return None
183+ 
184+ async def fake_set_current_agent(agent_name, _working_dir):
185+ persisted_agents.append(agent_name)
181 186 
182 async def fake_get_agent_selection(_agents, _current):187 async def fake_get_agent_selection(_agents, _current):
183 return "Minos"188 return "Minos"
184 189 
185 monkeypatch.setattr(agents_module.initializer, "load_agents_config", fake_load_agents_config)190 monkeypatch.setattr(agents_module.initializer, "load_agents_config", fake_load_agents_config)
186 monkeypatch.setattr(agents_module.initializer, "load_agent_config", fake_load_agent_config)191 monkeypatch.setattr(agents_module.initializer, "load_agent_config", fake_load_agent_config)
187- monkeypatch.setattr(agents_module.initializer, "update_default_agent", fake_update_default_agent)192+ monkeypatch.setattr(agents_module.initializer, "get_current_model", fake_get_current_model)
193+ monkeypatch.setattr(agents_module.initializer, "set_current_agent", fake_set_current_agent)
188 monkeypatch.setattr(agents_module.uuid, "uuid4", lambda: "new-thread-id")194 monkeypatch.setattr(agents_module.uuid, "uuid4", lambda: "new-thread-id")
189 monkeypatch.setattr(agents_module.console, "print_warning", warnings.append)195 monkeypatch.setattr(agents_module.console, "print_warning", warnings.append)
190 monkeypatch.setattr(agents_module.console, "print", lambda *_args, **_kwargs: None)196 monkeypatch.setattr(agents_module.console, "print", lambda *_args, **_kwargs: None)
@@ -200,6 +206,7 @@ async def test_agents_handler_updates_context_when_different_agent_selected(
200 assert context_updates.get("thread_id") == "new-thread-id"206 assert context_updates.get("thread_id") == "new-thread-id"
201 assert context_updates.get("current_input_tokens") is None207 assert context_updates.get("current_input_tokens") is None
202 assert context_updates.get("current_output_tokens") is None208 assert context_updates.get("current_output_tokens") is None
209+ assert persisted_agents == ["Minos"]
203 assert warnings == ["Switched to Agent: Minos. A new conversation has started."]210 assert warnings == ["Switched to Agent: Minos. A new conversation has started."]
204 211 
205 212 
@@ -164,15 +164,17 @@ async def test_model_handler_updates_context_on_successful_switch(
164 async def fake_load_llms_config(_working_dir):164 async def fake_load_llms_config(_working_dir):
165 return config_data165 return config_data
166 166 
167- async def fake_update_agent_llm(_agent, _model, _working_dir):167+ persisted_models: list[tuple[str, str]] = []
168- pass168+ 
169+ async def fake_set_current_model(agent, model, _working_dir):
170+ persisted_models.append((agent, model))
169 171 
170 async def fake_get_model_selection(_models, _current, _default):172 async def fake_get_model_selection(_models, _current, _default):
171 return "fast"173 return "fast"
172 174 
173 monkeypatch.setattr(models_module.initializer, "load_agent_config", fake_load_agent_config)175 monkeypatch.setattr(models_module.initializer, "load_agent_config", fake_load_agent_config)
174 monkeypatch.setattr(models_module.initializer, "load_llms_config", fake_load_llms_config)176 monkeypatch.setattr(models_module.initializer, "load_llms_config", fake_load_llms_config)
175- monkeypatch.setattr(models_module.initializer, "update_agent_llm", fake_update_agent_llm)177+ monkeypatch.setattr(models_module.initializer, "set_current_model", fake_set_current_model)
176 178 
177 handler = ModelHandler(session)179 handler = ModelHandler(session)
178 monkeypatch.setattr(handler, "_get_model_selection", fake_get_model_selection)180 monkeypatch.setattr(handler, "_get_model_selection", fake_get_model_selection)
@@ -180,6 +182,7 @@ async def test_model_handler_updates_context_on_successful_switch(
180 await handler.handle()182 await handler.handle()
181 183 
182 assert context_updates.get("model") == "fast"184 assert context_updates.get("model") == "fast"
185+ assert persisted_models == [("Profiler", "fast")]
183 186 
184 187 
185@pytest.mark.asyncio188@pytest.mark.asyncio
@@ -25,18 +25,11 @@ import pytest
25 25 
26from msagent.configs.registry import ConfigRegistry26from msagent.configs.registry import ConfigRegistry
27from msagent.core.constants import CONFIG_APPROVAL_FILE_NAME, LLM_CONFIG_VERSION27from msagent.core.constants import CONFIG_APPROVAL_FILE_NAME, LLM_CONFIG_VERSION
28+from msagent.core.paths import AppPaths
28from msagent.skills.factory import SkillFactory29from msagent.skills.factory import SkillFactory
29from msagent.tools.internal.memory import DEFAULT_MEMORY_FILE_CONTENT30from msagent.tools.internal.memory import DEFAULT_MEMORY_FILE_CONTENT
30 31 
31 32 
32-def _load_default_msprof_server() -> dict:
33- config_path = files("resources")
34- for part in ("configs", "default", "config.mcp.json"):
35- config_path = config_path.joinpath(part)
36- config = json.loads(config_path.read_text(encoding="utf-8"))
37- return config["mcpServers"]["msprof-mcp"]
38- 
39- 
40def _load_default_profiler_config() -> dict:33def _load_default_profiler_config() -> dict:
41 config_path = files("resources")34 config_path = files("resources")
42 for part in ("configs", "default", "agents", "Profiler.yml"):35 for part in ("configs", "default", "agents", "Profiler.yml"):
@@ -58,82 +51,44 @@ def _load_default_minos_config() -> dict:
58 return yaml.safe_load(config_path.read_text(encoding="utf-8"))51 return yaml.safe_load(config_path.read_text(encoding="utf-8"))
59 52 
60 53 
61-def test_default_msprof_server_uses_packaged_executable() -> None:54+def _create_registry(tmp_path: Path) -> tuple[ConfigRegistry, AppPaths]:
62- default_msprof_server = _load_default_msprof_server()55+ app_paths = AppPaths.from_home(tmp_path / "global-home" / ".msagent")
56+ working_dir = tmp_path / "workspace"
57+ working_dir.mkdir()
58+ return ConfigRegistry(working_dir, app_paths), app_paths
63 59 
64- assert default_msprof_server["command"] == "msprof-mcp"60+ 
65- assert default_msprof_server["args"] == []61+def test_default_mcp_config_is_valid_json() -> None:
66- assert default_msprof_server["repair_timeout"] == 3062+ config_path = files("resources")
67- assert "invoke_timeout" in default_msprof_server63+ for part in ("configs", "default", "config.mcp.json"):
68- assert isinstance(default_msprof_server["invoke_timeout"], (int, float))64+ config_path = config_path.joinpath(part)
69- assert default_msprof_server["invoke_timeout"] > 065+ 
66+ config = json.loads(config_path.read_text(encoding="utf-8"))
67+ 
68+ assert isinstance(config.get("mcpServers"), dict)
70 69 
71 70 
72@pytest.mark.asyncio71@pytest.mark.asyncio
73async def test_config_registry_bootstraps_default_layout(tmp_path: Path) -> None:72async def test_config_registry_bootstraps_default_layout(tmp_path: Path) -> None:
74- registry = ConfigRegistry(tmp_path)73+ registry, app_paths = _create_registry(tmp_path)
75 74 
76 await registry.ensure_config_dir()75 await registry.ensure_config_dir()
77 76 
78- config_dir = tmp_path / ".msagent"77+ assert app_paths.config_dir.is_dir()
79- assert config_dir.exists()78+ assert list(app_paths.config_dir.iterdir()) == []
80- assert (config_dir / "README.md").exists()79+ assert registry.project_paths.metadata_file.is_file()
81- assert (config_dir / "agents").is_dir()80+ assert registry.project_paths.memory_file.is_file()
82- assert (config_dir / "llms").is_dir()81+ assert not (registry.working_dir / ".msagent").exists()
83- assert (config_dir / "sandboxes").is_dir()
84- assert (config_dir / "subagents").is_dir()
85- assert (config_dir / "config.llms.yml").exists()
86- assert (config_dir / "config.mcp.json").exists()
87- assert (config_dir / CONFIG_APPROVAL_FILE_NAME.name).exists()
88- assert (config_dir / "memory.md").exists()
89 82 
90- mcp_config = json.loads((config_dir / "config.mcp.json").read_text())83+ agents = await registry.load_agents()
91- assert "msprof-mcp" in mcp_config["mcpServers"]84+ assert "Profiler" in agents.agent_names
92- msprof_server = mcp_config["mcpServers"]["msprof-mcp"]85+ assert registry.load_approval().interrupt_on
93- default_msprof_server = _load_default_msprof_server()
94- assert msprof_server["args"] == default_msprof_server["args"]
95- assert msprof_server["stateful"] == default_msprof_server["stateful"]
96- 
97- approval_config = json.loads((config_dir / CONFIG_APPROVAL_FILE_NAME.name).read_text(encoding="utf-8"))
98- assert "interrupt_on" in approval_config
99- assert "execute" in approval_config["interrupt_on"]
100- assert approval_config["interrupt_on"]["execute"]["allowed_decisions"] == [
101- "approve",
102- "reject",
103- ]
104- assert "decision_rules" in approval_config
105- assert any(
106- rule
107- == {
108- "name": "execute",
109- "args": {"command": ".*"},
110- "decision": "always_approve",
111- }
112- for rule in approval_config["decision_rules"]
113- )
114- 
115- profiler = yaml.safe_load((config_dir / "agents" / "Profiler.yml").read_text())
116- modeling = yaml.safe_load((config_dir / "agents" / "Modeling.yml").read_text())
117- minos = yaml.safe_load((config_dir / "agents" / "Minos.yml").read_text())
118- default_profiler = _load_default_profiler_config()
119- default_modeling = _load_default_modeling_config()
120- default_minos = _load_default_minos_config()
121- assert profiler["name"] == "Profiler"
122- assert profiler["tools"]["patterns"] == default_profiler["tools"]["patterns"]
123- assert profiler["skills"]["patterns"] == default_profiler["skills"]["patterns"]
124- assert modeling["name"] == "Modeling"
125- assert modeling["tools"]["patterns"] == default_modeling["tools"]["patterns"]
126- assert modeling["skills"]["patterns"] == default_modeling["skills"]["patterns"]
127- assert "modeling:*" in modeling["skills"]["patterns"]
128- assert modeling["default"] is False
129- assert minos["name"] == "Minos"
130- assert minos["skills"]["patterns"] == default_minos["skills"]["patterns"]
131 86 
132 87 
133@pytest.mark.asyncio88@pytest.mark.asyncio
134async def test_load_user_memory_ignores_default_template(tmp_path: Path) -> None:89async def test_load_user_memory_ignores_default_template(tmp_path: Path) -> None:
135- registry = ConfigRegistry(tmp_path)90+ registry, _ = _create_registry(tmp_path)
136- memory_file = tmp_path / ".msagent" / "memory.md"91+ memory_file = registry.project_paths.memory_file
137 memory_file.parent.mkdir(parents=True)92 memory_file.parent.mkdir(parents=True)
138 memory_file.write_text(DEFAULT_MEMORY_FILE_CONTENT, encoding="utf-8")93 memory_file.write_text(DEFAULT_MEMORY_FILE_CONTENT, encoding="utf-8")
139 94 
@@ -142,8 +97,8 @@ async def test_load_user_memory_ignores_default_template(tmp_path: Path) -> None
142 97 
143@pytest.mark.asyncio98@pytest.mark.asyncio
144async def test_load_user_memory_wraps_user_content(tmp_path: Path) -> None:99async def test_load_user_memory_wraps_user_content(tmp_path: Path) -> None:
145- registry = ConfigRegistry(tmp_path)100+ registry, _ = _create_registry(tmp_path)
146- memory_file = tmp_path / ".msagent" / "memory.md"101+ memory_file = registry.project_paths.memory_file
147 memory_file.parent.mkdir(parents=True)102 memory_file.parent.mkdir(parents=True)
148 memory_file.write_text("- 用户喜欢中文回复\n", encoding="utf-8")103 memory_file.write_text("- 用户喜欢中文回复\n", encoding="utf-8")
149 104 
@@ -151,10 +106,11 @@ async def test_load_user_memory_wraps_user_content(tmp_path: Path) -> None:
151 106 
152 107 
153@pytest.mark.asyncio108@pytest.mark.asyncio
154-async def test_config_registry_adds_missing_modeling_skill_patterns_to_existing_config_dir(109+async def test_config_registry_user_agent_replaces_packaged_agent(
155 tmp_path: Path,110 tmp_path: Path,
156) -> None:111) -> None:
157- config_dir = tmp_path / ".msagent"112+ registry, app_paths = _create_registry(tmp_path)
113+ config_dir = app_paths.config_dir
158 agents_dir = config_dir / "agents"114 agents_dir = config_dir / "agents"
159 agents_dir.mkdir(parents=True)115 agents_dir.mkdir(parents=True)
160 (agents_dir / "Modeling.yml").write_text(116 (agents_dir / "Modeling.yml").write_text(
@@ -163,7 +119,7 @@ async def test_config_registry_adds_missing_modeling_skill_patterns_to_existing_
163 "version": "__APP_VERSION__",119 "version": "__APP_VERSION__",
164 "name": "Modeling",120 "name": "Modeling",
165 "description": "legacy local modeling config",121 "description": "legacy local modeling config",
166- "prompt": ["prompts/agents/Modeling.md"],122+ "prompt": "custom prompt",
167 "llm": "default",123 "llm": "default",
168 "checkpointer": "sqlite",124 "checkpointer": "sqlite",
169 "default": False,125 "default": False,
@@ -176,16 +132,59 @@ async def test_config_registry_adds_missing_modeling_skill_patterns_to_existing_
176 encoding="utf-8",132 encoding="utf-8",
177 )133 )
178 134 
179- registry = ConfigRegistry(tmp_path)135+ modeling = await registry.get_agent("Modeling")
180 136 
181- await registry.ensure_config_dir()137+ assert modeling.description == "legacy local modeling config"
138+ assert modeling.prompt == "custom prompt"
139+ assert modeling.skills is not None
140+ assert modeling.skills.patterns == ["default:custom-local-skill"]
182 141 
183- modeling = yaml.safe_load((agents_dir / "Modeling.yml").read_text(encoding="utf-8"))142+ 
184- patterns = modeling["skills"]["patterns"]143+@pytest.mark.asyncio
185- default_modeling = _load_default_modeling_config()144+async def test_generated_agent_override_contains_only_mutated_fields(tmp_path: Path) -> None:
186- for pattern in default_modeling["skills"]["patterns"]:145+ registry, app_paths = _create_registry(tmp_path)
187- assert pattern in patterns146+ packaged = await registry.get_agent("Profiler")
188- assert "default:custom-local-skill" in patterns147+ 
148+ await registry.update_agent_llm("Profiler", "gpt-5-mini-thinking")
149+ 
150+ override_file = app_paths.config_dir / "agents" / "Profiler.yml"
151+ override = yaml.safe_load(override_file.read_text(encoding="utf-8"))
152+ assert set(override) == {"version", "name", "llm"}
153+ assert override["name"] == "Profiler"
154+ assert override["llm"] == "gpt-5-mini-thinking"
155+ assert not app_paths.prompts_dir.exists() or list(app_paths.prompts_dir.rglob("*")) == []
156+ 
157+ effective = await registry.get_agent("Profiler")
158+ assert effective.llm.alias == "gpt-5-mini-thinking"
159+ assert effective.prompt == packaged.prompt
160+ assert effective.tools == packaged.tools
161+ assert effective.skills == packaged.skills
162+ assert effective.compression == packaged.compression
163+ 
164+ 
165+@pytest.mark.asyncio
166+async def test_current_agent_is_scoped_to_project_state(tmp_path: Path) -> None:
167+ app_paths = AppPaths.from_home(tmp_path / "home")
168+ first_dir = tmp_path / "first"
169+ second_dir = tmp_path / "second"
170+ first_dir.mkdir()
171+ second_dir.mkdir()
172+ first = ConfigRegistry(first_dir, app_paths)
173+ second = ConfigRegistry(second_dir, app_paths)
174+ 
175+ await first.set_current_agent("Minos")
176+ 
177+ assert (await first.get_agent()).name == "Minos"
178+ assert (await second.get_agent()).name == "Profiler"
179+ assert not (app_paths.config_dir / "agents").exists()
180+ 
181+ 
182+@pytest.mark.asyncio
183+async def test_removed_workspace_agent_falls_back_to_packaged_default(tmp_path: Path) -> None:
184+ registry, _ = _create_registry(tmp_path)
185+ registry.project_paths.set_current_agent("RemovedAgent")
186+ 
187+ assert (await registry.get_agent()).name == "Profiler"
189 188 
190 189 
191@pytest.mark.asyncio190@pytest.mark.asyncio
@@ -199,7 +198,7 @@ async def test_default_skills_include_msmodeling_env_installer() -> None:
199async def test_config_registry_resolves_template_version_tokens_on_load(198async def test_config_registry_resolves_template_version_tokens_on_load(
200 tmp_path: Path,199 tmp_path: Path,
201) -> None:200) -> None:
202- registry = ConfigRegistry(tmp_path)201+ registry, _ = _create_registry(tmp_path)
203 202 
204 llms = await registry.load_llms()203 llms = await registry.load_llms()
205 204 
@@ -220,7 +219,8 @@ def test_default_agent_skill_bindings_are_split_between_profiler_and_minos() ->
220async def test_config_registry_preserves_existing_mcp_server_config(219async def test_config_registry_preserves_existing_mcp_server_config(
221 tmp_path: Path,220 tmp_path: Path,
222) -> None:221) -> None:
223- config_dir = tmp_path / ".msagent"222+ registry, app_paths = _create_registry(tmp_path)
223+ config_dir = app_paths.config_dir
224 config_dir.mkdir(parents=True)224 config_dir.mkdir(parents=True)
225 existing_mcp = {225 existing_mcp = {
226 "mcpServers": {226 "mcpServers": {
@@ -247,56 +247,47 @@ async def test_config_registry_preserves_existing_mcp_server_config(
247 encoding="utf-8",247 encoding="utf-8",
248 )248 )
249 249 
250- registry = ConfigRegistry(tmp_path)250+ mcp_config = await registry.load_mcp()
251- 251+ msprof_server = mcp_config.servers["msprof-mcp"]
252- await registry.ensure_config_dir()252+ assert msprof_server.args == existing_mcp["mcpServers"]["msprof-mcp"]["args"]
253- 253+ assert msprof_server.stateful is False
254- mcp_config = json.loads((config_dir / "config.mcp.json").read_text(encoding="utf-8"))254+ assert msprof_server.enabled is False
255- msprof_server = mcp_config["mcpServers"]["msprof-mcp"]
256- assert msprof_server["args"] == existing_mcp["mcpServers"]["msprof-mcp"]["args"]
257- assert msprof_server["stateful"] is False
258- assert msprof_server["enabled"] is False
259 255 
260 256 
261@pytest.mark.asyncio257@pytest.mark.asyncio
262-async def test_config_registry_adds_missing_default_mcp_server(258+async def test_config_registry_empty_override_keeps_packaged_mcp_servers(
263 tmp_path: Path,259 tmp_path: Path,
264) -> None:260) -> None:
265- config_dir = tmp_path / ".msagent"261+ registry, app_paths = _create_registry(tmp_path)
262+ config_dir = app_paths.config_dir
266 config_dir.mkdir(parents=True)263 config_dir.mkdir(parents=True)
267 (config_dir / "config.mcp.json").write_text(264 (config_dir / "config.mcp.json").write_text(
268 json.dumps({"mcpServers": {}}, ensure_ascii=False, indent=2),265 json.dumps({"mcpServers": {}}, ensure_ascii=False, indent=2),
269 encoding="utf-8",266 encoding="utf-8",
270 )267 )
271 268 
272- registry = ConfigRegistry(tmp_path)269+ mcp_config = await registry.load_mcp()
270+ default_path = registry.default_config_dir / "config.mcp.json"
271+ default_names = set(json.loads(default_path.read_text(encoding="utf-8"))["mcpServers"])
273 272 
274- await registry.ensure_config_dir()273+ assert set(mcp_config.servers) == default_names
275- 
276- mcp_config = json.loads((config_dir / "config.mcp.json").read_text(encoding="utf-8"))
277- msprof_server = mcp_config["mcpServers"]["msprof-mcp"]
278- default_msprof_server = _load_default_msprof_server()
279- assert msprof_server == default_msprof_server
280 274 
281 275 
282@pytest.mark.asyncio276@pytest.mark.asyncio
283-async def test_config_registry_copies_missing_approval_template_into_existing_config_dir(277+async def test_config_registry_reads_packaged_approval_without_copying_it(
284 tmp_path: Path,278 tmp_path: Path,
285) -> None:279) -> None:
286- config_dir = tmp_path / ".msagent"280+ registry, app_paths = _create_registry(tmp_path)
281+ config_dir = app_paths.config_dir
287 config_dir.mkdir(parents=True)282 config_dir.mkdir(parents=True)
288 (config_dir / "config.mcp.json").write_text(283 (config_dir / "config.mcp.json").write_text(
289 json.dumps({"mcpServers": {}}, ensure_ascii=False, indent=2),284 json.dumps({"mcpServers": {}}, ensure_ascii=False, indent=2),
290 encoding="utf-8",285 encoding="utf-8",
291 )286 )
292 287 
293- registry = ConfigRegistry(tmp_path)
294- 
295- await registry.ensure_config_dir()
296- 
297 approval_path = config_dir / CONFIG_APPROVAL_FILE_NAME.name288 approval_path = config_dir / CONFIG_APPROVAL_FILE_NAME.name
298- assert approval_path.exists()289+ approval_config = registry.load_approval()
299- approval_config = json.loads(approval_path.read_text(encoding="utf-8"))290+ 
300- assert "interrupt_on" in approval_config291+ assert not approval_path.exists()
301- assert "execute" in approval_config["interrupt_on"]292+ assert "execute" in approval_config.interrupt_on
302- assert "decision_rules" in approval_config293+ assert approval_config.decision_rules
@@ -0,0 +1,27 @@
1+from __future__ import annotations
2+ 
3+from collections.abc import Iterator
4+from pathlib import Path
5+ 
6+import pytest
7+ 
8+from msagent.cli.bootstrap.initializer import initializer
9+from msagent.core.paths import AppPaths
10+ 
11+ 
12+@pytest.fixture(autouse=True)
13+def isolate_msagent_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
14+ """Prevent unit tests from reading or writing the user's real msAgent home."""
15+ test_home = tmp_path / "msagent-home"
16+ test_paths = AppPaths.from_home(test_home)
17+ previous_paths = initializer.app_paths
18+ previous_registries = initializer._registries
19+ 
20+ monkeypatch.setenv("MSAGENT_HOME", str(test_home))
21+ initializer.app_paths = test_paths
22+ initializer._registries = {}
23+ try:
24+ yield
25+ finally:
26+ initializer.app_paths = previous_paths
27+ initializer._registries = previous_registries
@@ -0,0 +1,78 @@
1+from __future__ import annotations
2+ 
3+import json
4+from pathlib import Path
5+ 
6+from msagent.core.paths import AppPaths
7+ 
8+ 
9+def test_app_paths_uses_msagent_home(tmp_path: Path) -> None:
10+ home = tmp_path / "custom-home"
11+ 
12+ paths = AppPaths.resolve({"MSAGENT_HOME": str(home)})
13+ 
14+ assert paths.home == home.resolve()
15+ assert paths.metadata_file == home.resolve() / "metadata.json"
16+ assert paths.config_dir == home.resolve() / "config"
17+ assert paths.projects_dir == home.resolve() / "state" / "projects"
18+ 
19+ 
20+def test_project_paths_are_stable_and_isolated(tmp_path: Path) -> None:
21+ app_paths = AppPaths.from_home(tmp_path / ".msagent")
22+ first = tmp_path / "a" / "same-name"
23+ second = tmp_path / "b" / "same-name"
24+ first.mkdir(parents=True)
25+ second.mkdir(parents=True)
26+ 
27+ first_paths = app_paths.for_project(first)
28+ repeated = app_paths.for_project(first)
29+ second_paths = app_paths.for_project(second)
30+ 
31+ assert first_paths.project_id == repeated.project_id
32+ assert first_paths.project_id != second_paths.project_id
33+ assert first_paths.root.parent == app_paths.projects_dir
34+ 
35+ 
36+def test_project_ensure_writes_only_under_global_home(tmp_path: Path) -> None:
37+ app_paths = AppPaths.from_home(tmp_path / "global" / ".msagent")
38+ working_dir = tmp_path / "workspace"
39+ working_dir.mkdir()
40+ project = app_paths.for_project(working_dir)
41+ 
42+ project.ensure()
43+ 
44+ assert project.metadata_file.is_file()
45+ assert not (working_dir / ".msagent").exists()
46+ 
47+ 
48+def test_project_persists_agent_and_model_preferences(tmp_path: Path) -> None:
49+ app_paths = AppPaths.from_home(tmp_path / "global" / ".msagent")
50+ working_dir = tmp_path / "workspace"
51+ working_dir.mkdir()
52+ project = app_paths.for_project(working_dir)
53+ 
54+ project.set_current_agent("Minos")
55+ project.set_current_model("Minos", "fast")
56+ 
57+ assert project.get_current_agent() == "Minos"
58+ assert project.get_current_model("Minos") == "fast"
59+ assert project.get_current_model("Profiler") is None
60+ metadata = json.loads(project.metadata_file.read_text(encoding="utf-8"))
61+ assert metadata["working_dir"] == str(working_dir.resolve())
62+ assert metadata["current_agent"] == "Minos"
63+ assert metadata["agent_models"] == {"Minos": "fast"}
64+ 
65+ 
66+def test_project_preferences_are_isolated_by_workspace(tmp_path: Path) -> None:
67+ app_paths = AppPaths.from_home(tmp_path / "global" / ".msagent")
68+ first_dir = tmp_path / "first"
69+ second_dir = tmp_path / "second"
70+ first_dir.mkdir()
71+ second_dir.mkdir()
72+ first = app_paths.for_project(first_dir)
73+ second = app_paths.for_project(second_dir)
74+ 
75+ first.set_current_agent("Minos")
76+ 
77+ assert first.get_current_agent() == "Minos"
78+ assert second.get_current_agent() is None
@@ -0,0 +1,274 @@
1+from __future__ import annotations
2+ 
3+import json
4+from concurrent.futures import ThreadPoolExecutor
5+from pathlib import Path
6+ 
7+import pytest
8+ 
9+from msagent.core.paths import AppPaths
10+from msagent.core.storage_layout import (
11+ LEGACY_HOME_ENTRIES,
12+ STORAGE_LAYOUT_VERSION,
13+ StorageLayoutError,
14+ validate_and_initialize_storage_layout,
15+)
16+ 
17+ 
18+def _paths(tmp_path: Path) -> AppPaths:
19+ return AppPaths.from_home(tmp_path / "home")
20+ 
21+ 
22+def _write_metadata(paths: AppPaths, payload: object) -> None:
23+ paths.home.mkdir(parents=True, exist_ok=True)
24+ paths.metadata_file.write_text(json.dumps(payload), encoding="utf-8")
25+ 
26+ 
27+@pytest.mark.parametrize("precreate_home", [False, True])
28+def test_initializes_absent_or_empty_home(tmp_path: Path, precreate_home: bool) -> None:
29+ paths = _paths(tmp_path)
30+ if precreate_home:
31+ paths.home.mkdir()
32+ 
33+ validate_and_initialize_storage_layout(paths)
34+ 
35+ assert json.loads(paths.metadata_file.read_text(encoding="utf-8")) == {
36+ "storage_layout_version": STORAGE_LAYOUT_VERSION
37+ }
38+ assert paths.config_dir.is_dir()
39+ assert paths.projects_dir.is_dir()
40+ assert paths.logs_dir.is_dir()
41+ 
42+ 
43+@pytest.mark.parametrize("relative_dir", ["config", "state/projects"])
44+def test_backfills_metadata_for_existing_new_layout(tmp_path: Path, relative_dir: str) -> None:
45+ paths = _paths(tmp_path)
46+ (paths.home / relative_dir).mkdir(parents=True)
47+ 
48+ validate_and_initialize_storage_layout(paths)
49+ 
50+ assert paths.metadata_file.is_file()
51+ 
52+ 
53+def test_current_layout_is_idempotent_and_repairs_missing_directories(tmp_path: Path) -> None:
54+ paths = _paths(tmp_path)
55+ _write_metadata(paths, {"storage_layout_version": STORAGE_LAYOUT_VERSION})
56+ 
57+ validate_and_initialize_storage_layout(paths)
58+ first_content = paths.metadata_file.read_bytes()
59+ validate_and_initialize_storage_layout(paths)
60+ 
61+ assert paths.projects_dir.is_dir()
62+ assert paths.metadata_file.read_bytes() == first_content
63+ 
64+ 
65+@pytest.mark.parametrize("legacy_entry", LEGACY_HOME_ENTRIES)
66+def test_rejects_every_legacy_marker_without_writing(tmp_path: Path, legacy_entry: str) -> None:
67+ paths = _paths(tmp_path)
68+ marker = paths.home / legacy_entry.rstrip("/")
69+ if legacy_entry.endswith("/"):
70+ marker.mkdir(parents=True)
71+ else:
72+ marker.parent.mkdir(parents=True, exist_ok=True)
73+ marker.write_text("legacy", encoding="utf-8")
74+ before = {entry.name for entry in paths.home.iterdir()}
75+ 
76+ with pytest.raises(StorageLayoutError) as caught:
77+ validate_and_initialize_storage_layout(paths)
78+ 
79+ message = str(caught.value)
80+ assert str(paths.home) in message
81+ assert "请手动删除该目录后重新启动 msAgent" in message
82+ assert {entry.name for entry in paths.home.iterdir()} == before
83+ assert not paths.metadata_file.exists()
84+ 
85+ 
86+def test_legacy_error_requires_removing_the_whole_directory(tmp_path: Path) -> None:
87+ paths = _paths(tmp_path)
88+ marker = paths.home / "config.llms.yml"
89+ marker.parent.mkdir(parents=True)
90+ marker.write_text("legacy", encoding="utf-8")
91+ 
92+ with pytest.raises(StorageLayoutError) as caught:
93+ validate_and_initialize_storage_layout(paths)
94+ 
95+ message = str(caught.value)
96+ assert message.startswith("msAgent 启动已中止:检测到不兼容的旧版配置目录。")
97+ assert "您已升级 msAgent,但以下目录仍为旧版本结构" in message
98+ assert str(paths.home) in message
99+ assert "新版改为统一使用全局目录 ~/.msagent" in message
100+ assert "配置只需设置一次,即可在多个项目间共享" in message
101+ assert "各工作目录的运行状态独立保存,互不干扰" in message
102+ assert "由于新旧目录结构不兼容,无法直接复用" in message
103+ assert "请手动删除该目录后重新启动 msAgent" in message
104+ assert "启动时会自动创建新版目录结构" in message
105+ assert "⚠️ 删除前请注意" in message
106+ assert f"• 将清除:{paths.home} 下的所有配置、状态、历史及检查点" in message
107+ assert "• 如需保留数据,请提前备份" in message
108+ assert f"• {paths.skills_dir} 可能包含您自己添加的 Skill,请单独备份" in message
109+ assert "• 新版内置 Skill 由安装包提供,无需备份或恢复" in message
110+ assert "本次启动未对该目录做任何更改" in message
111+ _assert_no_shell_commands(message)
112+ 
113+ 
114+@pytest.mark.parametrize("shared_dir", ["skills", "prompts", "cache", "oauth", "logs"])
115+def test_shared_directory_alone_is_not_legacy(tmp_path: Path, shared_dir: str) -> None:
116+ paths = _paths(tmp_path)
117+ (paths.home / shared_dir).mkdir(parents=True)
118+ 
119+ validate_and_initialize_storage_layout(paths)
120+ 
121+ assert paths.metadata_file.is_file()
122+ 
123+ 
124+def test_rejects_mixed_layout_without_writing(tmp_path: Path) -> None:
125+ paths = _paths(tmp_path)
126+ paths.config_dir.mkdir(parents=True)
127+ legacy = paths.home / "config.llms.yml"
128+ legacy.write_text("legacy", encoding="utf-8")
129+ 
130+ with pytest.raises(StorageLayoutError, match="检测到新旧版本配置混用") as caught:
131+ validate_and_initialize_storage_layout(paths)
132+ 
133+ message = str(caught.value)
134+ assert message.startswith("msAgent 启动已中止:检测到新旧版本配置混用。")
135+ assert "同时存在新版结构和旧版残留" in message
136+ assert "config.llms.yml" in message
137+ assert str(paths.home) in message
138+ assert "新版改为统一使用全局目录 ~/.msagent" in message
139+ assert "配置只需设置一次,即可在多个项目间共享" in message
140+ assert "各工作目录的运行状态独立保存,互不干扰" in message
141+ assert "由于新旧目录结构不兼容,无法安全混用" in message
142+ assert "请手动删除该目录后重新启动 msAgent" in message
143+ assert "⚠️ 删除前请注意" in message
144+ assert "• 会清除:新版配置、项目状态、对话历史、检查点" in message
145+ assert "• 如需保留数据,请提前备份" in message
146+ assert f"• {paths.skills_dir} 可能包含您自己添加的 Skill,请单独备份" in message
147+ assert "• 新版内置 Skill 由安装包提供,无需备份或恢复" in message
148+ assert "启动时会自动创建新版目录结构" in message
149+ assert "本次启动未对该目录做任何更改" in message
150+ _assert_no_shell_commands(message)
151+ assert not paths.metadata_file.exists()
152+ assert not paths.logs_dir.exists()
153+ 
154+ 
155+def test_mixed_layout_warning_lists_all_legacy_entries_and_user_skills_risk(tmp_path: Path) -> None:
156+ paths = _paths(tmp_path)
157+ paths.config_dir.mkdir(parents=True)
158+ paths.skills_dir.mkdir()
159+ (paths.home / "config.llms.yml").write_text("legacy", encoding="utf-8")
160+ (paths.home / "agents").mkdir()
161+ 
162+ with pytest.raises(StorageLayoutError) as caught:
163+ validate_and_initialize_storage_layout(paths)
164+ 
165+ message = str(caught.value)
166+ assert "config.llms.yml, agents/" in message
167+ assert f"• {paths.skills_dir} 可能包含您自己添加的 Skill,请单独备份" in message
168+ assert "• 新版内置 Skill 由安装包提供,无需备份或恢复" in message
169+ 
170+ 
171+def _assert_no_shell_commands(message: str) -> None:
172+ for command in ("rm -rf", "mv --", "Remove-Item", "Move-Item", "New-Item", "$backupDir"):
173+ assert command not in message
174+ 
175+ 
176+@pytest.mark.parametrize(
177+ "payload",
178+ [
179+ [],
180+ {},
181+ {"storage_layout_version": True},
182+ {"storage_layout_version": "2"},
183+ {"storage_layout_version": 0},
184+ {"storage_layout_version": -1},
185+ ],
186+)
187+def test_rejects_invalid_metadata_without_overwriting(tmp_path: Path, payload: object) -> None:
188+ paths = _paths(tmp_path)
189+ _write_metadata(paths, payload)
190+ original = paths.metadata_file.read_bytes()
191+ 
192+ with pytest.raises(StorageLayoutError):
193+ validate_and_initialize_storage_layout(paths)
194+ 
195+ assert paths.metadata_file.read_bytes() == original
196+ assert not paths.logs_dir.exists()
197+ 
198+ 
199+def test_rejects_invalid_json_without_overwriting(tmp_path: Path) -> None:
200+ paths = _paths(tmp_path)
201+ paths.home.mkdir()
202+ paths.metadata_file.write_text("{broken", encoding="utf-8")
203+ 
204+ with pytest.raises(StorageLayoutError, match="valid msAgent storage metadata"):
205+ validate_and_initialize_storage_layout(paths)
206+ 
207+ assert paths.metadata_file.read_text(encoding="utf-8") == "{broken"
208+ 
209+ 
210+@pytest.mark.parametrize("version", [1, STORAGE_LAYOUT_VERSION + 1])
211+def test_rejects_unsupported_layout_versions(tmp_path: Path, version: int) -> None:
212+ paths = _paths(tmp_path)
213+ _write_metadata(paths, {"storage_layout_version": version})
214+ 
215+ with pytest.raises(StorageLayoutError, match=f"version {version}"):
216+ validate_and_initialize_storage_layout(paths)
217+ 
218+ assert json.loads(paths.metadata_file.read_text(encoding="utf-8"))["storage_layout_version"] == version
219+ 
220+ 
221+def test_rejects_home_file_and_metadata_directory(tmp_path: Path) -> None:
222+ file_paths = _paths(tmp_path)
223+ file_paths.home.write_text("not a directory", encoding="utf-8")
224+ with pytest.raises(StorageLayoutError, match="home must be a directory"):
225+ validate_and_initialize_storage_layout(file_paths)
226+ 
227+ directory_paths = AppPaths.from_home(tmp_path / "other-home")
228+ directory_paths.metadata_file.mkdir(parents=True)
229+ with pytest.raises(StorageLayoutError, match="metadata must be a regular file"):
230+ validate_and_initialize_storage_layout(directory_paths)
231+ 
232+ 
233+def test_rejects_symlink_for_managed_directory(tmp_path: Path) -> None:
234+ paths = _paths(tmp_path)
235+ external = tmp_path / "external"
236+ external.mkdir()
237+ paths.home.mkdir()
238+ paths.config_dir.symlink_to(external, target_is_directory=True)
239+ 
240+ with pytest.raises(StorageLayoutError, match="regular directory"):
241+ validate_and_initialize_storage_layout(paths)
242+ 
243+ assert not paths.metadata_file.exists()
244+ assert not (external / "config.llms.yml").exists()
245+ 
246+ 
247+def test_concurrent_initialization_produces_complete_metadata(tmp_path: Path) -> None:
248+ paths = _paths(tmp_path)
249+ with ThreadPoolExecutor(max_workers=4) as executor:
250+ futures = [executor.submit(validate_and_initialize_storage_layout, paths) for _ in range(8)]
251+ for future in futures:
252+ future.result()
253+ 
254+ assert json.loads(paths.metadata_file.read_text(encoding="utf-8")) == {
255+ "storage_layout_version": STORAGE_LAYOUT_VERSION
256+ }
257+ assert not list(paths.home.glob(".metadata.json.*.tmp"))
258+ 
259+ 
260+def test_metadata_write_failure_leaves_no_partial_file(
261+ tmp_path: Path,
262+ monkeypatch: pytest.MonkeyPatch,
263+) -> None:
264+ paths = _paths(tmp_path)
265+ 
266+ def fail_replace(_source: Path, _target: Path) -> Path:
267+ raise PermissionError("read-only storage")
268+ 
269+ monkeypatch.setattr(Path, "replace", fail_replace)
270+ with pytest.raises(StorageLayoutError, match="Cannot write.*read-only storage"):
271+ validate_and_initialize_storage_layout(paths)
272+ 
273+ assert not paths.metadata_file.exists()
274+ assert not list(paths.home.glob(".metadata.json.*.tmp"))