已合并
[refactor] 全局配置目录与工作区状态分离 #228
fanglanyue创建于 8月22日
[refactor] 全局配置目录与工作区状态分离 #228
已合并
共 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: 创建运行时 Context | 147 | Chat->>Ctx: 创建运行时 Context |
| 148 | Ctx->>Init: load_agent_config / load_llm_config | 148 | 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: 创建 MCPClient | 151 | Init->>MCP: 创建 MCPClient |
| 152 | MCP-->>Init: 可见 MCP tools + module_map | 152 | MCP-->>Init: 可见 MCP tools + module_map |
| @@ -163,8 +163,8 @@ sequenceDiagram | |||
| 163 | 1. **CLI 兼容路由** | 163 | 1. **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 | ||
| 169 | 3. **缓存式装配** | 169 | 3. **缓存式装配** |
| 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.yml | 305 | +├─ config/ |
| 306 | -├─ config.mcp.json | 306 | +│ ├─ config.llms.yml |
| 307 | -├─ config.approval.json | 307 | +│ ├─ config.mcp.json |
| 308 | -├─ config.checkpoints.db | 308 | +│ ├─ config.approval.json |
| 309 | -├─ memory.md | 309 | +│ ├─ agents/ |
| 310 | -├─ .history | 310 | +│ ├─ 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/ | |||
| 396 | Skill 是面向工作流复用的轻量资产,当前扫描顺序为: | 390 | Skill 是面向工作流复用的轻量资产,当前扫描顺序为: |
| 397 | 391 | ||
| 398 | 1. `<working-dir>/skills` | 392 | 1. `<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 | ```bash | 599 | ```bash |
| 606 | msagent config --show | 600 | msagent 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 | ```json | 689 | ```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 新增 Agent | 717 | #### 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 | ||
| 511 | msprof-mcp 通过 stdio 传输协议与 MCP Host 通信,典型集成场景包括: | 511 | msprof-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 | ||
| 83 | 1. [架构概览](arch_overview.md):了解 CLI、Agent Factory、Tools、Skills、MCP 与中间件的分层关系 | 83 | 1. [架构概览](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 加载顺序 |
| 85 | 3. [Agent / Tool / Skill 过滤规则](../user_guide/agent-tool-skill-filter-rules.md):修改 Agent 能力边界前必读 | 85 | 3. [Agent / Tool / Skill 过滤规则](../user_guide/agent-tool-skill-filter-rules.md):修改 Agent 能力边界前必读 |
| 86 | 4. 与你改动相关的 Agent 指南或用户指南 | 86 | 4. 与你改动相关的 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 | ```shell | 109 | ```shell |
| 111 | - rm -rf .msagent | 110 | + pip install -U mindstudio-agent |
| 112 | - pip install mindstudio-agent | ||
| 113 | ``` | 111 | ``` |
| 114 | 112 | ||
| 115 | - 卸载 | 113 | - 卸载 |
| 116 | 114 | ||
| 117 | ```shell | 115 | ```shell |
| 118 | - rm -rf .msagent | ||
| 119 | pip uninstall mindstudio-agent | 116 | 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 | ```text | 9 | ```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 | ```json | 81 | ```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 | ||
| 127 | 1. `<working-dir>/skills` | 134 | 1. `<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 | ||
| 133 | 1. 项目根目录下的 `skills/` | 140 | 1. 项目根目录下的 `skills/` |
| 134 | -2. 内置 Skills | 141 | +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 | ```text | 109 | ```text |
| 110 | -<working-dir>/.msagent/conversation_history/<thread_id>.md | 110 | +~/.msagent/state/projects/<project-id>/conversation_history/<thread_id>.md |
| 111 | ``` | 111 | ``` |
| 112 | 112 | ||
| 113 | 例如: | 113 | 例如: |
| 114 | 114 | ||
| 115 | ```text | 115 | ```text |
| 116 | -/path/to/project/.msagent/conversation_history/8d6c4f3f-....md | 116 | +~/.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 | ```text | 143 | ```text |
| 144 | -<working-dir>/.msagent/conversation_history/<thread_id>.md | 144 | +~/.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 | ```text | 226 | ```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 | ```text | 7 | ```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 | ```text | 66 | ```text |
| 65 | -.msagent/logs/app.log | 67 | +~/.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.llm | 357 | 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 None | 359 | 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 | @staticmethod | 1074 | @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_file | 1080 | 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 | @staticmethod | 1085 | @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 | |||
| 25 | from typing import Any, Iterator | 25 | from typing import Any, Iterator |
| 26 | 26 | ||
| 27 | from msagent.audit.events import AuditEventType | 27 | from msagent.audit.events import AuditEventType |
| 28 | -from msagent.core.constants import CONFIG_AUDIT_DIR | ||
| 29 | 28 | ||
| 30 | 29 | ||
| 31 | def iter_json_values(text: str) -> Iterator[dict[str, Any]]: | 30 | def 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 payload | 45 | 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_DIR | 55 | + 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 None | 59 | return None |
| 54 | 60 | ||
| @@ -65,9 +71,13 @@ def resolve_audit_path(*, working_dir: Path, thread_id: str) -> Path | None: | |||
| 65 | class AuditReader: | 71 | class 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_id | 75 | 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 | ||
| 28 | from msagent.audit.events import AuditEvent, UserResponseEvent, UserTurnEvent | 28 | from msagent.audit.events import AuditEvent, UserResponseEvent, UserTurnEvent |
| 29 | from msagent.audit.protocol import ProtocolParseResult | 29 | from msagent.audit.protocol import ProtocolParseResult |
| 30 | -from msagent.core.constants import CONFIG_AUDIT_DIR | ||
| 31 | 30 | ||
| 32 | 31 | ||
| 33 | def resolve_audit_log_enabled(agent_config: object | None) -> bool: | 32 | def 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_id | 67 | self.thread_id = thread_id |
| 68 | self.agent_name = agent_name | 68 | self.agent_name = agent_name |
| 69 | self.enabled = False if enabled is None else enabled | 69 | self.enabled = False if enabled is None else enabled |
| 70 | - self._audit_dir = self.working_dir / CONFIG_AUDIT_DIR | 70 | + 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 | ) |
| 17 | from msagent.cli.theme import console | 17 | from msagent.cli.theme import console |
| 18 | from msagent.core.logging import configure_logging, get_logger | 18 | from 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 | ||
| 21 | def create_parser(): | 23 | def 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 | -) | ||
| 37 | from msagent.llms.factory import LLMFactory | 32 | from msagent.llms.factory import LLMFactory |
| 38 | from msagent.mcp.factory import MCPFactory | 33 | from msagent.mcp.factory import MCPFactory |
| 39 | from msagent.skills.factory import Skill, SkillFactory | 34 | from msagent.skills.factory import Skill, SkillFactory |
| @@ -49,7 +44,8 @@ if TYPE_CHECKING: | |||
| 49 | class Initializer: | 44 | class 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_cleanup | 157 | 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 | |||
| 15 | from msagent.cli.theme import console | 15 | from msagent.cli.theme import console |
| 16 | from msagent.configs import ApprovalMode | 16 | from msagent.configs import ApprovalMode |
| 17 | from msagent.configs.llm import LLMProvider | 17 | from msagent.configs.llm import LLMProvider |
| 18 | -from msagent.core.constants import APP_NAME, CONFIG_LLMS_FILE_NAME | 18 | +from msagent.core.constants import APP_NAME |
| 19 | 19 | ||
| 20 | LEGACY_PROVIDER_MAP = { | 20 | LEGACY_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 | ||
| 97 | Optional arguments: | 97 | Optional 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 | ||
| 108 | Examples: | 108 | Examples: |
| 109 | # Show current project-local configuration | 109 | # 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 parser | 244 | 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 parser | 281 | 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_NAME | 390 | + 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 | ||
| 409 | async def _show_config(registry, working_dir: Path) -> int: | 409 | async 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.llm | 411 | + 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.value | 415 | 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 0 | 454 | return 0 |
| @@ -23,6 +23,7 @@ class Context(BaseModel): | |||
| 23 | model_display: str | None = None | 23 | model_display: str | None = None |
| 24 | thread_id: str | 24 | thread_id: str |
| 25 | working_dir: Path | 25 | working_dir: Path |
| 26 | + state_dir: Path | None = None | ||
| 26 | approval_mode: ApprovalMode = ApprovalMode.ACTIVE | 27 | approval_mode: ApprovalMode = ApprovalMode.ACTIVE |
| 27 | bash_mode: bool = False | 28 | bash_mode: bool = False |
| 28 | current_input_tokens: int | None = None | 29 | 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.llm | 86 | llm_config = agent_config.llm |
| 81 | 87 | ||
| 82 | tool_output_max_tokens = agent_config.tools.output_max_tokens if agent_config.tools else None | 88 | 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.name | 90 | + 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_dir | 48 | 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 model | 69 | # 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.llm | 75 | + 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 | return | 48 | 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 | _ = args | 62 | _ = 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 | ||
| 25 | from msagent.core.logging import get_logger | 24 | from msagent.core.logging import get_logger |
| 26 | from msagent.core.settings import settings | 25 | from msagent.core.settings import settings |
| 27 | 26 | ||
| @@ -35,7 +34,8 @@ class InteractivePrompt: | |||
| 35 | self.context = context | 34 | self.context = context |
| 36 | self.commands = commands | 35 | self.commands = commands |
| 37 | self.session = session | 36 | self.session = session |
| 38 | - history_file = Path(context.working_dir) / CONFIG_HISTORY_FILE_NAME | 37 | + 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 | |||
| 8 | from typing import Any, Literal | 8 | from typing import Any, Literal |
| 9 | 9 | ||
| 10 | import yaml | 10 | import yaml |
| 11 | -from packaging import version as pkg_version | 11 | +from packaging.version import parse as parse_version # pylint: disable=no-name-in-module |
| 12 | from pydantic import BaseModel, Field, model_validator | 12 | from pydantic import BaseModel, Field, model_validator |
| 13 | 13 | ||
| 14 | from msagent.configs.base import VersionedConfig | 14 | from msagent.configs.base import VersionedConfig |
| @@ -53,7 +53,7 @@ class CompressionConfig(BaseModel): | |||
| 53 | 53 | ||
| 54 | 54 | ||
| 55 | class AuditLogConfig(BaseModel): | 55 | class 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 | @classmethod | 285 | @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: ToolsConfig | 290 | # 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: SkillsConfig | 316 | # 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_keep | 324 | # 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 missing | 339 | # 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 parameters | 356 | # 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 = None | 607 | + 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.parent | 611 | + 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.parent | 622 | + 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"] = None | 698 | 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 = None | 730 | + 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.parent | 734 | + 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.parent | 745 | + 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 @@ | |||
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | 4 | ||
| 5 | import asyncio | 5 | import asyncio |
| 6 | -import json | ||
| 7 | -import shutil | ||
| 8 | from importlib.resources import files | 6 | from importlib.resources import files |
| 9 | from pathlib import Path | 7 | from pathlib import Path |
| 8 | +from typing import Any, Callable, TypeVar | ||
| 10 | 9 | ||
| 11 | import yaml | 10 | import yaml |
| 11 | +from pydantic import BaseModel | ||
| 12 | 12 | ||
| 13 | from msagent.configs.agent import ( | 13 | from msagent.configs.agent import ( |
| 14 | AgentConfig, | 14 | AgentConfig, |
| @@ -23,7 +23,6 @@ from logging.handlers import TimedRotatingFileHandler | |||
| 23 | from pathlib import Path | 23 | from pathlib import Path |
| 24 | from typing import Any, cast | 24 | from typing import Any, cast |
| 25 | 25 | ||
| 26 | -from msagent.core.constants import CONFIG_LOG_DIR | ||
| 27 | from msagent.core.settings import settings | 26 | from 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_hook | 57 | 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_DIR | 120 | + 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 | |||
| 11 | from dataclasses import dataclass, field | 11 | from dataclasses import dataclass, field |
| 12 | from pathlib import Path | 12 | from pathlib import Path |
| 13 | 13 | ||
| 14 | -from msagent.cli.bootstrap.initializer import initializer | ||
| 15 | -from msagent.core.constants import CONFIG_SKILLS_DIR | ||
| 16 | from msagent.skills.factory import DEFAULT_SKILL_CATEGORY, SkillFactory | 14 | from msagent.skills.factory import DEFAULT_SKILL_CATEGORY, SkillFactory |
| 17 | 15 | ||
| 18 | SKILL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") | 16 | SKILL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") |
| @@ -45,8 +43,9 @@ class SkillInstallResult: | |||
| 45 | class SkillInstaller: | 43 | class 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_dir | 47 | 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_CATEGORY | 75 | 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_name | 78 | + 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_DIR | 136 | + 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 | |||
| 28 | from langchain_core.tools import ToolException, tool | 28 | from langchain_core.tools import ToolException, tool |
| 29 | from pydantic import BaseModel, Field | 29 | from pydantic import BaseModel, Field |
| 30 | 30 | ||
| 31 | +from msagent.core.paths import AppPaths | ||
| 31 | from msagent.skills.factory import ( | 32 | from 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 | ||
| 23 | from pathlib import Path | 23 | from pathlib import Path |
| 24 | 24 | ||
| 25 | -from msagent.core.constants import CONFIG_MEMORY_FILE_NAME | ||
| 26 | - | ||
| 27 | DEFAULT_MEMORY_FILE_CONTENT = """# msagent memory | 25 | DEFAULT_MEMORY_FILE_CONTENT = """# msagent memory |
| 28 | 26 | ||
| 29 | Store durable user preferences and project facts here. | 27 | Store 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_NAME | 43 | + 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_path | 88 | 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]: | |||
| 77 | def test_entrypoint_version_and_config_show(tmp_path: Path) -> None: | 77 | def 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 == 0 | 79 | assert version.returncode == 0 |
| 80 | - assert "msAgent" in version.stdout | 80 | + 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 == 0 | 90 | assert config.returncode == 0 |
| 84 | assert "Current Configuration" in config.stdout | 91 | assert "Current Configuration" in config.stdout |
| 85 | assert "LLM Provider" in config.stdout | 92 | 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 | ||
| 88 | def test_entrypoint_one_shot_tool_call_and_todo_render(tmp_path: Path) -> None: | 157 | def 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 == 0 | 204 | assert result.returncode == 0 |
| @@ -28,6 +28,7 @@ from msagent.cli.bootstrap.legacy import ( | |||
| 28 | render_version_info, | 28 | render_version_info, |
| 29 | ) | 29 | ) |
| 30 | from msagent.core.constants import APP_NAME | 30 | from msagent.core.constants import APP_NAME |
| 31 | +from msagent.core.storage_layout import StorageLayoutError | ||
| 31 | 32 | ||
| 32 | 33 | ||
| 33 | def test_create_session_parser_defaults_to_interactive_mode() -> None: | 34 | def 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 | + | ||
| 149 | def test_render_version_info_formats_version_payload(monkeypatch: pytest.MonkeyPatch) -> None: | 188 | def 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 | |||
| 27 | from msagent.agents.context import AgentContext | 27 | from msagent.agents.context import AgentContext |
| 28 | from msagent.cli.bootstrap.initializer import Initializer | 28 | from msagent.cli.bootstrap.initializer import Initializer |
| 29 | from msagent.configs import ToolApprovalConfig | 29 | from msagent.configs import ToolApprovalConfig |
| 30 | +from msagent.core.paths import AppPaths | ||
| 30 | from msagent.skills.factory import Skill | 31 | from 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 AgentContext | 122 | 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 None | 124 | 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 | |||
| 21 | import pytest | 21 | import pytest |
| 22 | 22 | ||
| 23 | from msagent.cli.bootstrap.initializer import Initializer | 23 | from msagent.cli.bootstrap.initializer import Initializer |
| 24 | -from msagent.core.constants import CONFIG_SKILLS_DIR | 24 | +from msagent.core.paths import AppPaths |
| 25 | from msagent.skills.factory import BASIC_SKILL_CATEGORY, DEFAULT_SKILL_CATEGORY, SkillFactory | 25 | from 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.asyncio | 59 | @pytest.mark.asyncio |
| 60 | async def test_skill_factory_loads_workspace_and_config_skills(tmp_path: Path) -> None: | 60 | async 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 skills | 78 | 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 | ||
| 124 | def test_initializer_resolves_default_skill_search_order(tmp_path: Path) -> None: | 125 | def 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_skills | 130 | 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_config | 49 | 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 | + | ||
| 65 | def test_agent_context_defaults_to_cwd() -> None: | 117 | def test_agent_context_defaults_to_cwd() -> None: |
| 66 | context = AgentContext() | 118 | context = AgentContext() |
| 67 | 119 | ||
| @@ -8,6 +8,8 @@ import yaml | |||
| 8 | from msagent.cli.dispatchers.commands import CommandDispatcher | 8 | from msagent.cli.dispatchers.commands import CommandDispatcher |
| 9 | from msagent.cli.handlers import add_skill as add_skill_module | 9 | from msagent.cli.handlers import add_skill as add_skill_module |
| 10 | from msagent.cli.handlers.add_skill import AddSkillHandler | 10 | from msagent.cli.handlers.add_skill import AddSkillHandler |
| 11 | +from msagent.cli.bootstrap.initializer import Initializer | ||
| 12 | +from msagent.core.paths import AppPaths | ||
| 11 | from msagent.skills.installer import SkillInstallError, SkillInstaller | 13 | from 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 True | 70 | assert session.needs_reload is True |
| 67 | assert session.running is False | 71 | assert session.running is False |
| @@ -176,15 +176,21 @@ async def test_agents_handler_updates_context_when_different_agent_selected( | |||
| 176 | return minos | 176 | return minos |
| 177 | return hermes | 177 | return hermes |
| 178 | 178 | ||
| 179 | - async def fake_update_default_agent(_agent_name, _working_dir): | 179 | + persisted_agents: list[str] = [] |
| 180 | - pass | 180 | + |
| 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 None | 207 | assert context_updates.get("current_input_tokens") is None |
| 202 | assert context_updates.get("current_output_tokens") is None | 208 | 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_data | 165 | return config_data |
| 166 | 166 | ||
| 167 | - async def fake_update_agent_llm(_agent, _model, _working_dir): | 167 | + persisted_models: list[tuple[str, str]] = [] |
| 168 | - pass | 168 | + |
| 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.asyncio | 188 | @pytest.mark.asyncio |
| @@ -25,18 +25,11 @@ import pytest | |||
| 25 | 25 | ||
| 26 | from msagent.configs.registry import ConfigRegistry | 26 | from msagent.configs.registry import ConfigRegistry |
| 27 | from msagent.core.constants import CONFIG_APPROVAL_FILE_NAME, LLM_CONFIG_VERSION | 27 | from msagent.core.constants import CONFIG_APPROVAL_FILE_NAME, LLM_CONFIG_VERSION |
| 28 | +from msagent.core.paths import AppPaths | ||
| 28 | from msagent.skills.factory import SkillFactory | 29 | from msagent.skills.factory import SkillFactory |
| 29 | from msagent.tools.internal.memory import DEFAULT_MEMORY_FILE_CONTENT | 30 | from 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 | - | ||
| 40 | def _load_default_profiler_config() -> dict: | 33 | def _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"] == 30 | 62 | + config_path = files("resources") |
| 67 | - assert "invoke_timeout" in default_msprof_server | 63 | + 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"] > 0 | 65 | + |
| 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.asyncio | 71 | @pytest.mark.asyncio |
| 73 | async def test_config_registry_bootstraps_default_layout(tmp_path: Path) -> None: | 72 | async 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.asyncio | 88 | @pytest.mark.asyncio |
| 134 | async def test_load_user_memory_ignores_default_template(tmp_path: Path) -> None: | 89 | async 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.asyncio | 98 | @pytest.mark.asyncio |
| 144 | async def test_load_user_memory_wraps_user_content(tmp_path: Path) -> None: | 99 | async 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.asyncio | 108 | @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 patterns | 146 | + packaged = await registry.get_agent("Profiler") |
| 188 | - assert "default:custom-local-skill" in patterns | 147 | + |
| 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.asyncio | 190 | @pytest.mark.asyncio |
| @@ -199,7 +198,7 @@ async def test_default_skills_include_msmodeling_env_installer() -> None: | |||
| 199 | async def test_config_registry_resolves_template_version_tokens_on_load( | 198 | async 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() -> | |||
| 220 | async def test_config_registry_preserves_existing_mcp_server_config( | 219 | async 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.asyncio | 257 | @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.asyncio | 276 | @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.name | 288 | 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_config | 291 | + 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_config | 293 | + 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")) | ||