milu
stephonGAO/milu/CLAUDE.md
milu:统一的 AI 模型抽象层 + Agent 编排引擎。支持 9 个 LLM 提供商(通义千问、Kimi、GLM、DeepSeek、MiniMax、豆包、ChatGPT、Gemini、Claude),提供工具系统(含 MCP 协议)、子 Agent、内置工具、技能(Skills)、文件化系统提示词、会话持久化、上下文自动压缩、向量知识库(RAG)、运行追踪(可观测性)、以及多用户并发资源池等能力。 技术栈:Python 3.10+、openai SDK(作为统一 HTTP 客户端)、hatchling 构建、pytest + pytest-asyncio。 Windows 环境,虚拟环境解释器路径为 .venv/Scripts/python(非 .venv/bin/python)。 新增/修改 [project.scripts] 入口点后需 pip install -e . 重装才能注册控制台脚本。 六层架构,全部 async-first。核心数据流:AgentPool(可选)→ Agent.run() 循环 → LLM.chat() 流式 → 解析工具调用 → ToolExecutor 执行 → 回传 → 重复。
CLAUDE.md7 starsChanged 4 months ago
- Reads credentials
- Installs packages
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
milu:统一的 AI 模型抽象层 + Agent 编排引擎。支持 9 个 LLM 提供商(通义千问、Kimi、GLM、DeepSeek、MiniMax、豆包、ChatGPT、Gemini、Claude),提供工具系统(含 MCP 协议)、子 Agent、内置工具、技能(Skills)、文件化系统提示词、会话持久化、上下文自动压缩、向量知识库(RAG)、运行追踪(可观测性)、以及多用户并发资源池等能力。
技术栈:Python 3.10+、openai SDK(作为统一 HTTP 客户端)、hatchling 构建、pytest + pytest-asyncio。
## 常用命令
```bash
# 安装(开发模式)
pip install -e ".[dev]"
# 运行全部单元测试(跳过真实 API 调用)
.venv/Scripts/python -m pytest tests/ --ignore=tests/test_real_api.py --ignore=tests/test_real_new_providers.py -q
# 运行单个测试文件
.venv/Scripts/python -m pytest tests/test_agent.py -v
# 运行单个测试方法
.venv/Scripts/python -m pytest tests/test_agent.py::TestAgent::test_method_name -v
# 真实 API 集成测试(需要 .env 中配置对应 API Key)
.venv/Scripts/python tests/run_tests.py
# 运行示例(examples/ 下编号示例,从 LLM → Agent → 工具 → MCP → 子Agent → 服务)
.venv/Scripts/python "examples/1. basic_llm.py"
.venv/Scripts/python examples/7_server_fastapi.py # FastAPI 单 Agent 服务
.venv/Scripts/python examples/multi_user_chat.py # AgentPool 多用户并发
# CLI 命令行(pip install 后注册入口点 milu)
milu # 无子命令 → 进入交互式对话(chat;首次无 Key 时自动询问进入 setup 引导)
milu setup # 初始化引导:选厂商/模型 → API Key → 搜索工具(密钥写 ~/.milu/.env)
milu chat -p deepseek # 指定厂商进入对话(默认嵌入调度引擎,定时任务对话期间自动执行;--no-scheduler 关)
milu run "你好" -q # 一次性执行,-q 只输出最终回答(可管道;不嵌入调度)
echo "总结这段话" | milu run # 从 stdin 读取指令
milu providers # 列出 9 个厂商及 Key 配置状态
milu config set provider qwen # 写入 ~/.milu/config.json
milu sessions list # 查看历史会话
milu trace list # 近期运行列表(可观测性;show <id> 看 span 树瀑布、compare 多次对比、stats 聚合)
milu serve # 启动内置 Web 服务(多用户对话 + 全功能演示前端,默认 127.0.0.1:8000)
milu serve --port 9000 --no-scheduler # 自定义端口、不嵌入定时任务调度
# 开发期未重装时也可:.venv/Scripts/python -m milu.cli <args>
```
> Windows 环境,虚拟环境解释器路径为 `.venv/Scripts/python`(非 `.venv/bin/python`)。
> 新增/修改 `[project.scripts]` 入口点后需 `pip install -e .` 重装才能注册控制台脚本。
## 架构概览
六层架构,全部 async-first。核心数据流:`AgentPool`(可选)→ `Agent.run()` 循环 → `LLM.chat()` 流式 → 解析工具调用 → `ToolExecutor` 执行 → 回传 → 重复。
### 1. LLM 层 (`src/milu/llm/`)
- `BaseLLM`(抽象基类)定义统一接口:`chat(messages, **kwargs) -> AsyncIterator[StreamChunk]`
- `ModelCapabilities`(frozen dataclass)声明提供商能力(function_calling, streaming, reasoning, `max_context_window` 等)
- `ModelRegistry` 工厂模式:每个 provider 文件末尾自注册 `ModelRegistry.register("name", Class)`
- **关键设计**:所有 9 个 provider 统一使用 `openai.AsyncOpenAI` 客户端,通过不同 `base_url` + `extra_body` 适配各提供商。`AsyncOpenAI` 协程安全,**LLM 实例可在多用户间安全共享**
- `ModelConfig` 基类 + 扩展配置(`WebSearchConfig`, `ThinkingConfig`, `FunctionCallingConfig` 等)
### 2. Agent 层 (`src/milu/agent/`)
- `Agent.run()` 核心循环(`agent.py`,~880 行):每轮 `重建 system prompt → 自动压缩 → LLM 流式调用 → 解析文本/工具调用 → 安全检查 → 执行工具 → 回传结果`,返回 `AsyncIterator[AgentEvent]`
- 事件类型(`events.py`):`TextDelta`, `ReasoningDelta`, `ToolCallPreparing`(参数流式生成期状态信号,每个调用首次出现发一次), `ToolCallStart`, `SafetyCheckStart`(AI 安全判定开始,阻塞式 LLM 调用期间的状态信号), `ToolConfirmRequired`, `ToolResult`, `AgentDone`, `AgentError`, `SubAgentEvent`, `SubAgentDone`, `HistoryCompacted`, `SessionLoaded`。两个状态信号事件覆盖原本零事件的静默窗口(正文结束→参数生成完、判定调用中),消费者可忽略
- **Agent 构造(全配默认,开箱即用)**:`Agent(llm)` 即得完整体——顶层 Agent 各参数为 `None`(默认)时自动注入:内置 `main` 角色提示词(`prompt_dir`,传了 `system_prompt` 则只用它)、内置技能(`skills_dir`)、**全套内置工具 `BUILTIN_TOOLS`(`tools`,显式 `[]` 即无工具)、内置子代理三件套(`subagents`,显式 `[]` 即关闭)**。统一约定:`None`→内置默认、`[]`/显式值→覆盖;子代理 `register_catalog=False`,所有默认注入对其不生效(保持精简 + 结构性不嵌套)。**「全配默认」只在 Agent 一处实现——直接构造与经 AgentPool 构造拿到同规格实例**。能力参数 `mode` / `session_enabled` / `session_dir` / `mcp_tools_active_by_default` / `subagents` 是 `Agent.__init__` 的直接参数(`AgentConfig` 仅含运行限额 `max_turns`/`timeout`/`total_timeout`/`max_total_tokens`/`tool_call_limit`)。`session_dir` 默认 `~/.milu/sessions`(与 CWD 解耦,可用环境变量 `MILU_HOME` 覆盖;见 `resources.user_data_dir()`)
- **操作模式 `AgentMode`(枚举定义于 `config.py`,作为 `Agent(mode=...)` 直接参数)** —— 安全模型的核心,运行时可 `agent.set_mode(...)` 切换(写实例字段 `self._mode`,天然无跨用户串扰):
- `talk`:只读,调用前用 `_is_safe_call()` 拦截所有不安全工具
- `manual`:人工审批,安全工具直接执行、不安全工具产出 `ToolConfirmRequired` 等待审批
- `auto`(默认):自主决策(类 Claude Code),不安全工具自动执行;可选配 AI 安全判定器兜底(见下)
- `superwork`:全权限,跳过所有安全检查(含 AI 判定)
- **AI 安全判定器(`judge.py`,对齐 Claude Code auto mode 分类器)**:**默认启用**——`judge_llm` 参数 `None`(默认)→ 复用主 llm;`False` → 关闭;`BaseLLM 实例` → 指定判定模型(建议便宜快速模型);`judge_rules` 追加自定义规则文本。仅 auto 模式生效:`is_safe`/`safe_check` 快路径不经 AI(零开销),其余不安全调用**批量一次**交判定器三态裁决——`allow`→执行 / `confirm`→转 `on_confirm` 人工审批(无回调则拒绝)/ `deny`→拒绝并把理由回传 LLM。判定调用以 `temperature=0` 发起(确定性输出;不支持该参数的 provider 自动过滤)。**判定失败一律 fail-open**(回退为直接执行+警告日志,判定器是增强安全网而非硬门槛)。经 ContextVar `_current_parent_judge` 继承给子代理,父关闭时子也关闭(委派不旁路判定);`judge_llm` 为 AsyncOpenAI 封装可跨 Agent 共享
- **工具执行顺序**:todo 计划工具(`todo_write`/`todo_read`)必须单独成批调用(不可与其他工具混在一批);普通工具一批内通过 `asyncio.gather` 并发执行。**注**:原「跨轮次顺序守卫」(开始干活后禁止再建计划)已移除——它把"已开始工作"等同于"调用过不安全工具",无法区分「用强力工具做只读调查」与「真正动手修改」(如 `ls ... 2>&1` 曾被 `_is_safe_shell` 误判为写操作),反复误拦合法的「调查→列计划→执行」工作流。现对齐 Claude Code:任何时候都可创建/更新 todo 计划,仅保留上述"计划工具不与其他工具同批"的批次隔离约束
- `ConversationHistory`(`history.py`):消息列表 + 4 种截断策略(`none`, `sliding_window`, `token_limit`, `head_tail`),内部持有 `Compactor`
- `Session`(`session.py`):会话持久化,每会话一个 `{session_dir}/{id}/` 目录(默认 `~/.milu/sessions/`),`conversation.jsonl`(append-only 消息日志,SYSTEM 不记录)+ `session.json`(元数据)。支持 compaction 快照点恢复。**日志分段**:`Agent.reset()` 在同一会话目录内切换到新空日志段 `conversation.N.jsonl`(旧段保留归档),活动段 = 目录中段号最大者(磁盘即真相),load/计数只针对活动段 → reset 后加载不含旧历史
- `Compactor`(`compactor.py`):上下文自动压缩流水线,**0/1 次 API 调用分层**:L1 消息数裁剪 → 轮次分层工具结果压缩(旧轮→占位符、中间轮→截断、近期轮→保留,均带 session 文件指针)→ L4 超 `trigger_ratio` 时 LLM 摘要。阈值随 `max_context_window` 动态计算。另提供 `compact` 元工具供 LLM 主动触发
- `SubAgent`(`subagent.py`):`create_subagent_tools()` 工厂为每个 `SubAgentConfig` 生成一个 `@tool` 闭包;调用时**每次新建独立 Agent + 干净历史**,`register_catalog=False`/不嵌套子 Agent(结构性保证)。**模式与确认回调均通过 ContextVar 继承**(`_current_parent_mode` / `_current_parent_confirm`,`Agent.run()` 入口注入)——无需 `get_parent_mode` 回调、子代理工具可在 Agent 之前创建且跨 Agent 共享;AUTO 模式下子代理内的不安全工具同样走父的人工确认(**委派不构成安全旁路**)。`SubAgentConfig.role`(main/coder/researcher/reader/reviewer)便利字段自动套用对应内置角色提示词
- **内置子代理三件套**:`builtin_subagent_configs()` 返回生产级预设——`researcher` 调研员(web_search/web_fetch/datetime + deep-research 技能,只读)、`reader` 长内容阅读员(file_read/web_fetch/image_read/doc_read,定向提取,只读)、`coder` 编码执行员(python_repl/file_read/file_write);可选 `reviewer` 审查员(include 加入)。选型标准:上下文隔离 / 权限收窄 / 可并行
### 3. 工具层 (`src/milu/tools/`)
- `@tool(name, description, is_safe=True, safe_check=None)` 装饰器 → 生成 `ToolWrapper`(含自动 JSON Schema、`is_safe`、`safe_check` 动态判定、`meta` 元工具标记)。**注意:旧版的 `dangerous`/`priority` 参数已移除**
- `ToolRegistry` 双池设计(`registry.py`):active pool(schema 注入 LLM)+ dormant pool(MCP 工具待激活)
- `catalog.py` 提供三个元工具(`list_catalog`, `search_tools`, `activate_tools`)供 LLM 自主发现/激活 dormant 工具
- `ToolExecutor`(`executor.py`)安全执行:JSON 参数解析、async/sync 兼容、异常捕获
- 内置工具(`builtin/`):`file_tool`(read/write 分开;对二进制文档/图片扩展名返回 doc_read/image_read 引导;**相对路径锚定 agent 工作区**,见下方设计约束)、`image_read`(**图片视觉输入**,见下)、`doc_read`(**Office/PDF 文档提取**:docx 段落+表格转 Markdown、xlsx/xlsm/xls 按 sheet 读取上限 500 行、pdf 按页码范围默认前 20 页、pptx 逐页;.doc/.ppt 老格式返回转换指引;解析库 python-docx/openpyxl/pypdf/python-pptx/xlrd 为**核心硬依赖**)、`shell_command`、`python_repl`(**两者的实际执行委派给可插拔沙箱后端**,见下方「沙箱执行后端」)、`http_request`(API/JSON 场景)、`web_fetch`(网页→Markdown 正文提取,阅读场景优先,省 token)、`web_search`(**可插拔后端**:环境变量 `WEB_SEARCH_PROVIDER`=ddg 默认/tavily/bocha + 对应 Key;DDG 国内不可用,国内部署配 bocha 或用 LLM 自带搜索。**ddgs 为可选依赖 `milu[ddg]` 延迟导入**——其底层 primp 是 Rust 扩展,无 wheel 平台(Termux/Alpine)装不上,未安装时 ddg 后端运行时友好报错、不阻断 `import milu`)、`datetime_tool`、`structured_output`、`todo_write`、`memory_write/memory_read`(长期记忆,**不在 BUILTIN_TOOLS 默认列表**,由 `Agent(memory=...)` 开关启用时自动注册,见下方设计约束)、`kb_search/kb_ingest/kb_manage`(**向量知识库**,同样不在 BUILTIN_TOOLS,由 `Agent(knowledge=...)` 开关启用,见下方设计约束)
- **图片视觉输入(`image_tool.py` + `llm/base/vision.py`)**:让视觉模型"看到"本地图片。两条入口:① LLM 自主调 `image_read(path)`(校验存在/格式 png/jpg/jpeg/webp/gif/bmp/≤10MB/视觉能力),路径登记到 per-run ContextVar `_current_pending_images`,Agent 在该批工具结果回填后**追加注入一条多模态 user 消息**;② 程序化 `Agent.run(user_input, images=[...])`。**轻量引用块设计**:历史/会话日志只存 `{"type":"image_path","path":...}`(不含 base64,JSONL 不膨胀、token 估算不失真、会话可恢复),发送 API 时才在 `BaseLLM._messages_to_dicts()` 单点物化为 base64 data URL(chatgpt Responses API 经 `_convert_user_content` 转 input_image);物化失败降级为 text 占位块不中断对话。能力声明是 provider 级(仅 deepseek 无视觉),实际需配视觉模型(如 qwen-vl-plus)
- **沙箱执行后端(`src/milu/sandbox/`,见 `docs/沙箱执行方案设计.md`)**:`python_repl`/`shell_command` 的**实际执行**委派给可插拔后端,在「软门控」(模式+AI判定决定要不要跑)之上补「执行时隔离」(决定跑在哪/能碰什么),二者正交。`SandboxBackend`(ABC) + `ExecResult` + 三实现:`SubprocessBackend`(**默认**,独立子进程 `python -X utf8 -I -` 喂 stdin / shell 子进程——超时**真杀进程**、`setrlimit` 限内存/CPU(**内存硬限额仅 Linux 可靠**:macOS 内核不强制 RLIMIT_AS、Win 跳过)、**env 清洗隐藏 `*_API_KEY`**、**注入 guarded-open 拦截读 `.env`/写 milu 源码(`_wrap_python`:受保护清单由父进程 `get_protected_paths()` 算好以字面量注入子进程,并以 `compile('<repl>')` 保留用户代码异常行号)**、**默认继承当前工作目录**(`ephemeral_workdir=True` 改用一次性临时目录),跨平台零依赖)、`LocalBackend`(进程内 exec/宿主 shell,零依赖、零子进程开销,可信场景的退回档)、`DockerBackend`(**真隔离,多用户/不可信输入首选**:每次一次性容器 `docker run --rm --network none --read-only` + `-v {工作区}:/work`(只挂该用户工作区、宿主其余不可见)+ 不传宿主 env(密钥天然不可见)+ 限内存/CPU/PID + 超时 `docker kill`;**纯走 docker CLI、零 Python 依赖**——`pip install milu` 无需额外包、`import` 不依赖 docker 存在,仅 `backend=docker` 且真正执行时才需本机 Docker Engine;懒探测 daemon + 按需拉镜像,未装/未启/拉取失败返回中文友好错不崩对话)。**为何 subprocess 当默认**:local 进程内 exec 的三缺口(能读全部 `os.environ` 密钥、软超时杀不掉死循环线程、崩溃波及主进程)是「同进程执行」的固有属性,进程内补不上——唯一修复手段就是放进子进程;subprocess 再补 guarded-open + 继承 CWD 使其不弱于 local,故 = local 全部防护 + 三项硬提升。⚠️ guarded-open 是「随手访问」防护(与 local 同档,determined 代码仍可经 `io.open`/`os.open` 绕过;硬隔离待 docker)。**两个「默认」之别**:应用层默认 = subprocess(经 config.json);**hermetic 回退恒 = LocalBackend**(`default_backend()`,未经 Agent 注入的裸工具调用/单测走它,零子进程开销)——故裸 `Agent(llm)`(`sandbox=None`→注入 None→回退 local)仍 hermetic。**注入与 knowledge 同款**:`Agent(sandbox=...)`(None→不注入回退 local;`"local"`/`SandboxConfig`/实例→定制)→ `run()` 入口 `_current_sandbox` ContextVar 注入、`finally` `_safe_reset`、`__aexit__` `close_sandbox()`;`ToolExecutor` 零改动。**配置**:`config.json` `sandbox` 分节(从 `SandboxConfig` dataclass 派生,默认 `backend=subprocess`、`timeout=60`),`milu config set sandbox.backend local` 退回零开销、`sandbox.backend docker` 切真隔离(多用户安全,需本机 Docker;另有 `docker_image`/`docker_cpus`/`docker_user`/`docker_mounts`/`network`)、`sandbox.ephemeral_workdir true` 加强工作目录隔离;CLI `build_agent` / Web `agent_factory` 读分层配置 `SandboxConfig.from_mapping` 下传。**黑名单/受保护路径检查保留在工具层**(后端无关纵深防御,先于执行)
### 4. MCP 层 (`src/milu/tools/mcp/`)
- 支持三种传输:`stdio`, `streamable_http`, `sse`
- `MCPManager` 多服务器编排:`asyncio.gather` 并行连接,错误隔离
- `converter.py` 将 MCP Tool 转为 `ToolWrapper`,工具名加 `{server_name}__` 前缀避免冲突
- 配置文件搜索顺序(未显式传 `mcp_config_path` 时):`{project_dir()}/config/mcp_servers.json`(项目级「读配置」,默认 CWD,可用 `MILU_PROJECT_DIR` 覆盖)→ `~/.milu/mcp_servers.json`(用户级兜底,可用 `MILU_HOME` 覆盖);亦可用环境变量 `MCP_CONFIG_PATH` 指定绝对路径
### 5. 提示词 & 技能层 (`src/milu/prompts/`, `skills/`)
- `PromptBuilder`(`prompts/builder.py`):从 Markdown 目录**分层拼装** system prompt。每个 `.md` 是一个片段,YAML frontmatter 控制 `section`(safeguard/soul/agent/memory/custom) + `order` + `enabled`,`{{key}}` 变量插值,**每次 `build()` 重读文件支持热重载**。预置角色提示词随包分发在 `src/milu/templates/prompts/{main,coder,researcher,reviewer}/`,通过 `milu.builtin_prompts_dir(role)` 定位(内置技能同理用 `builtin_skills_dir()`)
- `SkillRegistry`(`skills/registry.py`):技能**元数据(name/description/triggers)始终注入 system prompt**,正文按需通过 `load_skill` 元工具拉取。无激活/卸载生命周期。支持平铺 `skills/x.md` 或子目录 `skills/x/SKILL.md`;**多文件技能**(目录内含 examples/reference/scripts 等附属文件)在 load_skill 返回时自动注明技能目录绝对路径,供 file_read 访问附属资源
- **内置技能 9 个**:自研 4 个(skill-creator/deep-research/content-writing/doc-formatting)+ 移植 5 个(官方 anthropics/skills Apache-2.0:frontend-design/internal-comms/mcp-builder;社区 obra/superpowers MIT:systematic-debugging/test-driven-development)。来源与许可见 `templates/skills/THIRD_PARTY_NOTICES.txt`。⚠️ 官方 docx/pdf/pptx/xlsx 四件套为专有许可禁止再分发,**不可移植**。(translator/code-review 曾内置,已删)
### 6. 服务层 (`src/milu/serving/`)
- `AgentPool`(`pool.py`):多用户并发资源池。**按 `(user_id, session_id)` 缓存独立 Agent 实例**,`async with pool.acquire(uid, sid) as h: h.agent.run(...)`。LRU + idle TTL 淘汰、全局 `Semaphore` 并发限流、后台 sweep 清理、`get_stats()` 监控(含 hit_rate)
- 便利构造:**`AgentPool.from_llm(llm)`** 一行起池(共享同一 LLM 实例,AsyncOpenAI 协程安全)。「全配默认」由 Agent 自身提供(见 Agent 层),默认工厂只叠加服务层语义:确定性 session_id 派生 + 共享 MCP 注入;运行限额经 `agent_config=AgentConfig(...)`、其余 Agent 参数经 `agent_kwargs={"mode": ..., "tools": [...], "subagents": [...]}` 原样透传
- 四个硬不变量:每个 `(user_id, session_id)` ≤1 实例;实例数 ≤ `max_agents`;并发 run ≤ `max_concurrent_runs`;空闲超 `idle_ttl_seconds` 被清理
- `pool.remove(user_id, session_id)`:主动驱逐并关闭指定实例(`_global_lock` 下复用 `_close_entry`),供「运行时切换设置后按新配置重建」用;运行中也可移除(运行协程持自身 agent 引用不受影响,共享 MCP 由池拥有不被 `disconnect_mcp` 断开)
### 6.5 内置 Web 服务 (`src/milu/serving/web/`,CLI `milu serve` 一键启动)
把 AgentPool(多用户 SSE 流式对话)+ 可选嵌入式 ScheduleEngine(定时任务,`--no-scheduler` 关)+ 共享 MCP,沉淀为**包内置的正规服务**,配一个**全功能单页演示前端**(`static/index.html`,纯 vanilla 无构建链/无外部 CDN,随 wheel 分发)。
- **应用工厂 `create_app(...)`**(`app.py`)+ 入口 `run_server(host, port, reload, **opts)`;CLI `_cmd_serve` 经 `resolve_settings` 取默认厂商/模型/模式后启动 uvicorn
- **依赖**:fastapi/uvicorn/sse-starlette 自 0.1.0 起为**核心依赖**(随 milu 一并安装,简化 `pip install milu` 即开箱体验);子包 `__init__` 仍保留延迟导入(仅 create_app/run_server 时才真正 import),即便依赖被手动卸载 `import milu.serving.web` 也不报错。⚠️ `app.py` 顶层必须导入 `Request` 等 FastAPI 类型——配合 `from __future__ import annotations`,函数内局部导入会让 FastAPI 无法从模块全局解析字符串注解(误判为 query 参数 → 422)
- **运行时切换厂商/模型/模式**:`app.state.prefs[(user,session)]` 存偏好覆盖、`app.state.llm_cache[(provider,model,...)]` 按型号缓存共享 LLM(AsyncOpenAI 协程安全);自定义 `agent_factory` 读偏好取缓存 LLM 构造 Agent(memory/schedule_user 按 user_id 派生隔离)。`POST /api/settings` 写偏好 + `pool.remove()` 驱逐 → 下条消息按新设置重建;`POST /api/mode` 即时 `set_mode` 并存偏好
- **确认流队列桥接**:危险工具确认时 `on_confirm` 会**阻塞** `agent.run()` 生成器,故后台任务跑 `agent.run()` 喂 `asyncio.Queue`,`on_confirm` 在 `await future` 前把「ConfirmationRequest」推进队列,SSE 协程独立排空队列——保证弹窗在阻塞期间即时显示;`POST /api/confirm`(按 user+session 定位 Future)解析放行,120s 超时自动拒绝
- **端点**:`/`(前端)、`/api/chat`(SSE,`/` 命令复用 `_exec_command`)、`/api/confirm`、`/api/upload`(聊天附件)、`/api/providers`、`/api/settings`、`/api/mode`、`/api/sessions` + `/api/session/action`(new/load/save/reset)、`/api/schedule/{tasks,create,action,results}`、`/api/tools|skills|memory|stats`
- **聊天附件上传分析**(`POST /api/upload` + `/api/chat` 的 `files` 参数):前端 📎 选择 / 粘贴截图 / 拖拽,文件经 base64 JSON 通道上传(与知识库入库一致,不引入 python-multipart)落 `~/.milu/uploads/{safe_user}/`(时间戳前缀防同名覆盖,上传时顺带清理 7 天过期附件;图片 10MB、其他 30MB 上限,单条消息 ≤10 个);chat 端校验每个路径必须在**本用户**上传目录内(防伪造任意磁盘路径读他人/系统文件)——图片扩展名经 `Agent.run(images=...)` 走视觉通路,全部附件以说明块附在用户消息尾引导模型用 doc_read/file_read/image_read 读取分析;只传附件不输文字时自动用默认指令
- **定时任务结果提醒**:服务端 `notify=False`(无桌面弹窗),前端全局轮询 `/api/schedule/results`(15s),新结果插入聊天区系统行 + toast、任务面板自动刷新(首轮只记游标不回放历史);run_at 输入用 `datetime-local` 控件防格式错
- **知识库管理端点**(`/api/knowledge*`,按 X-User-Id 隔离库):`GET /api/knowledge`(生效设置 + 统计 + 来源清单)、`POST /api/knowledge/settings`(切换 enabled/auto_retrieve → 写 per-(user,session) 偏好 `kb_enabled`/`kb_auto_retrieve` + `pool.remove` 驱逐重建,knowledge 为 Agent 构造期参数必须重建)、`POST /api/knowledge/ingest`(文本 text+source 或文件 filename+content_base64,复用 kb_ingest 工具完整链路——经 ContextVar 注入临时 KnowledgeRuntime,文件落临时目录入库后清理,避免引入 python-multipart 依赖)、`POST /api/knowledge/action`(delete 按来源 / clear 清空)。生效配置 `_kb_effective` = config.json `knowledge` 分节 + 偏好覆盖
- 前端五面板:设置(厂商下拉含 Key 状态/模型/模式/开关)、会话、定时任务(创建表单 + 启停/删除/立即运行 + 结果轮询)、**知识库(启用/自动检索开关 + 文件多选上传/文本入库 + 来源列表/删除/清空 + 统计)**、信息(工具/技能/记忆/统计);主区流式渲染(文本/思考/工具/子代理 + 轻量 Markdown)+ 聊天附件(📎/粘贴截图/拖拽)+ 危险工具确认弹窗
- **观测大屏(数据中心,`src/milu/serving/web/dashboard.py` + `static/dashboard.html`,路由 `/dashboard`)**:与上面**单用户对话 SPA 正交的管理员/运维视角**——**跨所有用户**俯瞰运行指标、资源池、Agent 链路、安全审计、定时任务与每用户数据画像。三块设计:
- **鉴权(跨用户读全局数据必须门控)**:`check_admin` 校验 `MILU_ADMIN_TOKEN`(header `X-Admin-Token` / `Authorization: Bearer` / 查询参数 `token`,`secrets.compare_digest` 常量时间比较);**未设置令牌时默认仅 localhost 可访问**(回环地址判定)。敏感正文(对话/记忆原文)默认不进聚合,仅在显式下钻(trace span 树 / 会话加载)时按需返回——「默认元数据、下钻看正文」
- **实时枢纽 `DashboardHub`**:进程级事件枢纽(环形缓冲 + 订阅队列,满则丢最旧保活)。`agent_factory` 给每个 Agent 的 `TraceConfig.extra_sinks` 注入一个 `CallbackSink`(`make_span_sink`),span 完成即 `span_to_event` 压成精简事件推入枢纽;调度 `on_result` 也推入。`/api/dashboard/stream`(SSE) 订阅枢纽广播给大屏。**Agent 核心零改动**,纯增强式埋点(复用既有 `extra_sinks` 机制)
- **跨用户聚合**:聚合口径下沉到 `observability/analytics.py`(`summarize_runs`/`group_stats`/`timeseries`/`percentile`,**CLI `trace stats` 与大屏共用一套口径**,杜绝两处漂移);叠加资源池实时态(新增 `AgentPool.list_active_sessions()` 遍历 `_entries` 出活跃会话快照)+ 会话/记忆/知识库/定时任务的每用户 rollup(`_enumerate_user_ids` 归并各用户级数据目录文件名 = `safe_user_id`;短 TTL 缓存 `_cached` 避免轮询打爆磁盘扫描)
- **端点**(全部经 `check_admin`):`GET /api/dashboard/{overview,pool,users,runs,trace/{id},analytics,audit,scheduler}` + `GET /api/dashboard/stream`(SSE)。trace 端点经 `all_users` 索引定位 `trace_path` 跨用户读 span 树
- **前端**:纯 vanilla 暗色科技皮肤(深空蓝底 + 霓虹辉光 + 等宽数字)、**手写轻量 SVG 图表**(面积折线/半圆仪表盘/环形/横向柱状/链路瀑布,零依赖、随 wheel 分发,不引 CDN/图表库);顶部 9 宫格 KPI + 运行量时序 + 资源池仪表 + 安全审计环形 + 模型成本柱状 + 用户画像表 + 实时事件流 + 活跃会话 + 定时任务 + 近期运行(点击下钻 span 瀑布浮层)。轮询(5~15s) + SSE 实时事件流双通道;`milu serve` 启动横幅打印 `/dashboard` 地址与令牌提示
- 示例 `examples/multi_user_chat.py` / `examples/scheduler_server.py` 保留作教学(本服务正是其能力的内置化整合)
### 7. CLI 层 (`src/milu/cli/`)
- 入口点:`pyproject.toml` 的 `[project.scripts]` 注册 `milu`,解析到 `milu.cli:main`;亦支持 `python -m milu.cli`
- `app.py`:argparse 命令面 + `main()`。子命令 `chat`(无子命令时的默认)/ `run`(一次性,支持 stdin 管道、`-q` 只输出最终文本)/ `setup`(初始化引导)/ `config`(show/path/get/set/init)/ `sessions`(list/show)/ `providers` / `version`。全局选项(`-p/--provider`、`-m/--model`、`--api-key`、`--mode`、`--no-session/--no-mcp/--no-subagents`)写在子命令**之后**。`config get/set` 用**点号路径**操作分层配置(如 `milu config set agent.max_turns 50` 写用户级),`config init` 在项目生成 `config/milu.json` 全量模板。`main()` 统一捕获 `AuthenticationError`/`ValueError`/`MiluError` 给中文友好提示与退出码
- `config.py`(`src/milu/config.py`,**核心分层配置**,见下「分层配置体系」)+ `cli/config.py`(CLI 参数层:`Settings` + `resolve_settings()`,把文件配置叠加 CLI 参数 + 解析 API Key)。**解析优先级**:provider/model/mode 等 = CLI 参数 > 用户 `~/.milu/config.json` > 项目 `config/milu.json` > 内置默认;api_key = CLI 参数 > 环境变量 `{PROVIDER}_API_KEY`(**密钥不再落 config.json**)。注意:配置里的 `model` 只在「未切换厂商」时沿用,避免给 deepseek 套上 qwen 的模型名
- `setup_wizard.py`(`milu setup` **初始化引导**,pip 安装后零手工配置):4 步交互——选厂商(带中文名/Key 状态/默认模型)→ 选模型 → API Key(带各厂商申请地址,已有 Key 回车保留)→ 搜索后端(bocha/tavily/ddg + 对应 Key);可选发一次最小请求验证 Key。**密钥写用户级 `~/.milu/.env`**(`update_env_file` 合并写:已有键原位更新、注释保留),厂商/模型经 `set_user_value` 写 `~/.milu/config.json`;写入后同步 `os.environ` 当前进程即时生效。配套 `_env.py` 的 `ensure_dotenv_loaded()` 在项目级 .env(CWD 向上)之后**兜底加载用户级 `~/.milu/.env`**(override=False,进程环境变量 > 项目级 > 用户级),任意目录运行 CLI 均生效。`milu chat` 入口(TTY 下)检测不到当前厂商 Key 时经 `offer_first_run_setup` 自动询问进入引导,完成后重新加载配置继续对话。输入统一剥离 BOM/零宽字符(Windows 管道喂入首行带 BOM)
- `builder.py`:`build_llm()` / `build_agent()`——**最简创建**,依托 Agent「全配默认」:tools/skills/prompt 不传 → 自动注入全套内置工具、内置技能、内置 main 提示词;`subagents=None` → 内置三件套(`--no-subagents` 传 `[]` 关闭);运行限额/压缩来自分层配置(`settings.agent` 构造 `AgentConfig`、`settings.compact` 经 `ConversationHistory` 传 `CompactConfig`)
- `render.py` / `repl.py`:终端渲染(ANSI 颜色、不安全工具确认回调、事件流渲染)与交互式 REPL(全部 `/命令`),**迁移并整理自 `examples/multi_turn_chat.py`**
- Windows 注意:`main()` 启动时 `os.system("")` 启用 ANSI;stdin 管道按 `sys.stdin.buffer` 显式 UTF-8 解码(规避控制台代码页 + surrogateescape 把中文解成孤立代理字符)
### 8. 调度层 (`src/milu/scheduler/`)
- **多用户定时任务**:`ScheduleStore`(`store.py`)按用户分文件 `~/.milu/schedules/{safe_user_id}.json`(与 memory 同一 safe 化规则;旧单文件 `schedules.json` 首次访问自动迁移为 `schedules/default.json`,源文件保留 `.migrated`)。任务名在**同一用户内**唯一;写盘一律原子写(mkstemp+fsync+os.replace);跨进程丢更新窗口与 session.py 同档取舍(POSIX flock / Windows 接受低概率)
- `ScheduleEngine`(`engine.py`):asyncio 主循环每分钟 tick,到期任务 `gather + Semaphore(max_concurrent_tasks)` 并发执行,store 写操作按 `task.user_id` 取 `asyncio.Lock` 防进程内丢更新;单任务 `wait_for(task_timeout)` 超时保护。`SchedulerConfig`(engine.py 顶部,比照 AgentPoolConfig 不单独建文件)进 config.json `scheduler` 分节
- **三种运行形态**(共用单实例锁,见下):① CLI 守护进程 `milu scheduler start`(前台 `start()`,`echo=True` 控制台回显);② 嵌入 Web 服务 `engine.start_background()`(FastAPI lifespan 中启动,见 `serving/web/app.py`);③ **嵌入 CLI chat**——`run_chat`(repl.py)启动时起**独立 daemon 线程**跑 `asyncio.run(engine.start())`,对话期间定时任务自动执行、退出即停(`--no-scheduler` 关,`Settings.use_scheduler`);⚠️ 不能用 `start_background()` 挂 REPL 主循环——REPL 的 `input()` 同步阻塞主线程事件循环(停在 You> 提示符时即冻结),tick 永远得不到调度;`milu run` 一次性命令不嵌入
- **单实例锁 `SchedulerLock`(`lock.py`,已从 CLI 层下沉)**:PID 文件 `~/.milu/scheduler.lock` + 跨平台进程检活 `pid_alive`(Windows 切勿 os.kill 探测)。多引擎并存会重复执行任务(tick 全量扫盘、无任务级防重),故 daemon/chat 嵌入/web 嵌入三方都走锁;`try_acquire()` 同进程重入幂等(探测与正式获取可分离);`release()` 仅持有者生效(非持有者 no-op,防误删他人锁);stale 锁(持有者已死)自动覆盖
- **锁守望/自动接管 `engine.start_with_lock(lock, retry_seconds=30)`**:嵌入方(chat 线程 / web `start_background(lock)`)抢到锁即运行,被占用则周期重试、**持锁者退出后自动接管**——chat 与 serve 同开时先开方退出,另一方接管执行,任务不会因锁竞争静默不执行(曾是「拿不到锁就永远不嵌入」的坑);等锁阶段 `stop()` 下一轮检查即退出且不碰他人的锁;daemon `milu scheduler start` 仍是「拿不到锁直接拒绝启动」(前台进程语义)
- **引擎韧性防护(曾修复 Web 嵌入任务静默不执行的 bug)**:`echo` 是 `ScheduleEngine.__init__` 参数(非 SchedulerConfig 项——运行形态差异由调用方代码决定,不进 config.json),`echo=False`(嵌入默认)完全不写 stdout,`echo=True` 经 `_echo_print` 容错编码(stdout 重定向为 GBK 时 `▶`/`✓` 曾抛 UnicodeEncodeError 被 gather 静默吞掉、任务永不执行);`_safe_tick` 单次 tick 异常不杀主循环;`start_background()` 带 done-callback,后台任务异常退出记 error 日志(无人 await 它)
- **执行器二选一**:注入 `agent_pool` → 任务经 `pool.get_or_create_agent(task.user_id, ...)` 执行(per-user 实例复用/共享 MCP/memory 派生,**不占在线并发许可**);不注入 → 每任务自建独立轻量 Agent(CLI 模式)
- **结果投递三通道**:outbox JSONL `~/.milu/scheduler_outbox/{user_id}.jsonl`(带 flock append)→ `on_result` 异步回调(服务端推送)→ 系统弹窗(`notify.py`,Windows ctypes MessageBoxW / macOS osascript / Linux notify-send;`SchedulerConfig.notify` 可关)
- **工具层用户上下文**(`schedule_tool.py`):`_current_schedule_user` ContextVar 由 `Agent.run()` 入口注入(`Agent(schedule_user=...)`,与 `memory` 平级的能力参数);**未注入时退化为 "default"(与 memory 的写拒绝是有意差异**,因 schedule 工具在 BUILTIN_TOOLS 默认列表,CLI 单人无注入须兼容);user_id 不暴露为 LLM 参数(防伪造他人身份)。AgentPool 默认工厂**默认**按 user_id 派生 `schedule_user`(不派生则全部用户共用 default 任务空间,跨用户可见/可删)
- **工具拆分(参数正交性原则)**:LLM 侧暴露两个工具——`schedule_create`(专职创建,9 个参数全部相关,整体不安全)+ `schedule_manage`(action=list/delete/enable/disable/run_now,schema 极简仅 action+name;`safe_check` 动态判定 list 只读安全,其余走审批/AI 判定)。不合并为单工具的原因:create 专属参数占比过高,管理操作的 schema 会充满无关参数噪音;也不再拆细——delete/enable/disable/run_now 形态一致(都只要 name)
### 9. 可观测性层 (`src/milu/observability/`)
- **解决 Agent 运行黑箱**:一次 `Agent.run()` = 一棵 span 树(trace),让运行可解释/可测量/可比较/可追溯/可视化。设计与业界调研(Langfuse/LangSmith/OTel GenAI/OpenAI Agents SDK/Claude Code)见 `docs/可观测性方案设计.md`
- **数据模型**(`span.py`/`tracer.py`):`invoke_agent` 根 span → `chat {model}`(generation,TTFT/usage 增量/finish_reasons)、`execute_tool`(tool)、`guardrail judge`(**三态裁决+理由+fail_open 标记**——judge 最需要可解释)、`blocked_on_user`(审批等待时长+决策,学 Claude Code 把"等人"从工具耗时剥离)、`compact` 子 span。ID 遵循 W3C Trace Context(32/16 hex)、属性名对齐 **OTel GenAI semconv v1.37+ 新名**(`gen_ai.provider.name`/`gen_ai.usage.input_tokens`,废弃名不用),milu 私有走 `milu.*`;JSONL 行带 `schema` 版本,将来 OTLP 导出零映射
- **接入与隔离**:`Agent(trace=False)` 库默认关(hermetic);True/`TraceConfig` 开启。Tracer/当前 span 经 **ContextVar 注入**(与 judge/todo 同模式)——子代理沿 asyncio 任务树自动继承父 tracer,其 `invoke_agent` 挂在父 `execute_tool` 之下(委派链路零代码可追溯,不受子代理自身开关影响);工具批次 gather 任务内各设自己的 span,并发天然隔离。**埋点全部包裹式,不改控制流;fail-silent**(sink 异常只记 warning);关闭时 NOOP 零开销
- **落盘**(`sink.py`):span 结束即 append——有 session → `{session_dir}/{id}/trace.jsonl`(与 conversation.jsonl 并排);无 session → `~/.milu/traces/{trace_id}.jsonl`(`retention_days` 过期清理)。顶层运行结束聚合 `RunReport`(`report.py`)追加运行索引。**索引按用户分文件 `~/.milu/runs/{safe_user_id}.jsonl`**(与 memory/scheduler/knowledge 同款 `{safe_user_id}` 约定,user_id 为 None→default.jsonl;Web 只读自己那份不扫他人,CLI `all_users` 聚合全部):每份各自 LRU 轮转上限 `RUNS_INDEX_MAX_LINES`(默认 5000,append 后超限即原子重写丢最旧行)——这是唯一全局永久追加的 JSONL,防其无限增长越读越慢;旧版单文件 `~/.milu/runs.jsonl` 首次访问按 user_id 自动拆分迁移(源改名 `.migrated`)。RunReport:token 合计按 generation span 求和**含子代理**,时间分解只算根直接子级避免重复计。Sink 可插拔(`extra_sinks`,Web 实时推送/OTLP 导出器均为一个 sink)
- **成本估算**(`pricing.py`,学 Langfuse 双轨制):token 用真实 usage,单价查表——config `observability.price_table`(最长前缀匹配)优先于内置示例表,**查不到 cost=None 绝不瞎算**
- **应用层开关**:config.json `observability` 分节(`enabled` 默认 **true**,仅本地落盘数据不出本机;`capture_content` full/truncated/none 默认 truncated 截 2000 字)——与 knowledge 同款"库默认关、应用层默认开"约定,CLI `build_agent` / Web `agent_factory` 读配置传 `TraceConfig.from_mapping`
- **跨运行聚合下沉 `analytics.py`**:`summarize_runs`/`group_stats`/`timeseries`/`percentile` 为纯函数(只吃 RunReport 行、无读盘无副作用),**CLI `trace stats` 与观测大屏共用同一套口径**,避免两处指标漂移
- **消费端**:① CLI `milu trace list/show/compare/stats`(`cli/trace_cmd.py`,树状渲染/对比表/p50/p95 聚合,CJK 宽度对齐用 `_pad`);② Web 单用户「观测」面板三端点 `GET /api/traces`、`/api/trace/{id}`(前缀匹配,按 X-User-Id 过滤防跨用户读取)、`/api/observability/summary`(聚合卡片 + 运行列表 + 瀑布图,纯 CSS 色块按 kind 着色,judge 理由直接展示);③ **跨用户观测大屏**(`/dashboard`,见 §6.5)——管理员视角,经 `MILU_ADMIN_TOKEN` 门控,复用 analytics 口径 + `DashboardHub` 实时 span 推送
### 10. 渠道接入 / 网关层 (`src/milu/channels/`,CLI `milu gateway`,见 `docs/Gateway 多渠道接入.md`)
把 milu Agent 接到各 IM 平台(微信客服 / 飞书 / Telegram…),**Ports & Adapters(六边形)**:平台无关的「接入核心」与每个平台的「适配器 Channel」解耦,新增平台 = 只写一个 Channel。本层只在显式 `import milu.channels`(或 `milu gateway`)时加载,**不进 `import milu`**;webhook 渠道(微信/飞书)的回调加解密走 cryptography(**已进核心依赖**,`pip install milu` 即可用,无需额外 extra)。唯一可选 extra 是 `milu[feishu-ws]`(lark-oapi ~57MB,仅飞书长连接本地开发模式需要)。
- **核心契约 `base.py`**:`InboundMessage`(channel/user_id/text/session_id/images/reply_to/raw/msg_id)+ `OutboundMessage` + `Dispatch` 类型 + `Channel` ABC(`name` + `register(app,dispatch)` webhook 挂路由 / `run(dispatch)` polling 长轮询,两默认 no-op / `close()`)。**统一契约**:渠道自己 ingest → 组 InboundMessage → `await dispatch(msg)` → 平台 API 回发 `reply.text`;webhook 与 polling 两形态都收敛到这一条
- **接 milu `runner.py`**:`AgentRunner` 持 `AgentPool`,`__call__(inbound)` 按 `渠道:用户` 派生 (uid,sid) → `pool.acquire` 跑一轮取 `AgentDone.final_text`(异常/空回复回退兜底语,绝不向渠道抛)。**这是接 milu 的唯一处**——替代早期手写 run.py 的 `on_text`。`from_llm(llm, agent_kwargs=...)` 便利构造,strict 隔离经 agent_kwargs 透传、视觉经 images
- **/ 命令 `commands.py`**:IM 消息以 `/` 开头 → `AgentRunner` 拦截走 `run_command(agent, cmd, is_admin, identity)` 返回**单条纯文本**(不喂 LLM),命令集对齐 CLI/Web 但收敛为一问一答 + 长度截断(`_MAX_LEN`,防超 IM 单条上限)。**默认关闭**(`AgentRunner(commands=False)` 库默认;CLI `milu gateway --commands` **或** config.json `gateway.commands=true` 开启,任一为真即开)——不开时 `/foo` 当普通消息照常喂 LLM。开启后**权限分层(面向公网陌生人)**:信息类(`/help /whoami /history /tools /skills /plan /memory /mode查看 /reset /compact /new /sessions`)对所有人;敏感类(`/mode 切换`含 superwork、`/prompt /load /save`)仅管理员,非管理员返回拒绝提示——**防陌生人经 `/mode superwork` 提权关掉所有安全检查**。⚠️ `/sessions` 虽开放给所有人但**只列调用者自己名下会话**(`_own_session_prefix(identity)` 按用户命名空间前缀过滤——网关多用户共用一个会话根目录、session ID 内含各自 user_id,不过滤会泄露他人会话)。管理员经环境变量 `GATEWAY_ADMINS` **∪** config.json `gateway.admins`(逗号分隔/列表 `渠道:用户ID` 或裸 `用户ID`,`AgentRunner._is_admin` 两形态都匹配);`/whoami` 让用户拿到自己的身份 ID 以便加白名单。`AgentRunner(admins={...})` 显式指定(覆盖 env)。**网关配置走 `config.json` `gateway` 分节(`commands`/`admins`)+ CLI 旗标 + env**(`commands` = 旗标 OR config;`admins` = env ∪ config);CLI 启动横幅打印「/ 命令开关 + 管理员数」
- **编排 `gateway.py`**:`Gateway(dispatch, channels, on_startup/on_shutdown)` / `from_runner(runner, channels)`。`build_app()`→FastAPI:webhook 路由在 build 期挂(lifespan 内加不生效)、polling 渠道在 lifespan 起后台任务、`/healthz` 列渠道、shutdown 取消任务+close 渠道+on_shutdown。`run()` 起 uvicorn
- **状态持久化 `state.py`**:`StateStore` ABC(`get_cursor`/`set_cursor`/`seen` 有界 LRU 去重)+ `InMemoryStateStore`(重启即丢)+ `FileStateStore`(落 `user_data_dir()/gateway/{channel}.json`,**游标+去重重启不丢**,原子写 mkstemp+fsync+os.replace + 去抖刷盘 + per-渠道 asyncio.Lock)。⚠️ `_write` 必须**同步**原子写(不用 `to_thread`)——构造 obj→写→清 dirty 之间无 await,否则 close() 取消 to_thread 写会丢失但 dirty 已清、flush() 兜底失效(修过的真 bug)
- **三渠道**:`wechat_kf.py`(`WeChatKfChannel` webhook;抽出无状态 `WeChatKfClient.fetch_messages(token,open_kfid,cursor)->(msgs,next_cursor)` 给 Channel 走 StateStore,旧 `sync_msg`/`handle_event`/`create_app`/`on_text` 保留**向后兼容**生产 run.py)、`feishu.py`(`FeishuChannel` 事件订阅 v2 + 内置 `FeishuCrypto` AES-256-CBC `key=sha256(encrypt_key)`/IV=密文前16字节 + `url_verification` 回 challenge + verify_token 校验 + `tenant_access_token` 发 `/im/v1/messages`,event_id 去重;**非消息回调/token 不符一律 ack 200 忽略不 403**——飞书对非 2xx 反复重投)、`feishu_ws.py`(`FeishuWsChannel` **长连接**模式,`FEISHU_MODE=ws` 启用,本地开发免隧道:官方 `lark-oapi` ws 客户端跑 daemon 线程 + `run_coroutine_threadsafe` 桥接回主循环 dispatch,发消息仍复用 `FeishuClient`;name 同为 "feishu" 使身份/会话与 webhook 一致;可选依赖 `milu[feishu-ws]`)、`telegram.py`(`TelegramChannel` polling,`run()` 循环 getUpdates→dispatch→sendMessage,offset 经 StateStore 持久化,**空轮询 `idle_backoff` 退避防 CPU 空转**,`TELEGRAM_API_BASE` 可指代理/自建 Bot API——国内连不上官方域名)
- **媒体接入 `media.py`(图片 + 文件)**:各渠道收到**图片/文件**类消息时用平台 API 下载二进制(微信 `media/get` / 飞书 `messages/:id/resources/:key?type=image|file` / Telegram `getFile`+`/file/bot…`),经 `save_image`/`save_file` 落到「**该用户工作区下 `_incoming` 子目录**」(与 agent_factory 同款 `channel:user_id` 派生工作区,使 strict 部署 `workspace_jail` 下文件工具仍读得到),把绝对路径填进 `InboundMessage.images`/`.files`。**AgentRunner `_compose_input`** 分流:图片经 `agent.run(images=...)` 走视觉物化(不经文件工具、不受围栏限制);图片/文件在消息尾追加统一「本条附件清单」`_format_attachments`(列各附件本地路径——图片标注「已提供视觉、可直接看图作答」、文件按扩展名标注「先用 doc_read/file_read 读取再回应」,并**显式叮嘱模型「回复里别提文件是否保存、存哪等内部细节」**——实践教训:只给「请查看图片」干瘪指令时,模型在 file-agent 人设下会主动跟用户解释「这图片/文件不在我工作区」之类误导废话,且易跨条串到上一条附件);只发媒体无配文时补默认指令 `DEFAULT_MEDIA_PROMPT`(否则 `run("")` 无可回应文本)。落盘带毫秒时间戳前缀防同名覆盖、图片超 `MAX_IMAGE_BYTES`(10MB)/文件超 `MAX_FILE_BYTES`(30MB) 跳过、图片按 content-type/文件名推断扩展名(视觉层靠扩展名识别格式)、文件保留平台原名(doc_read 靠扩展名选解析器;微信不在消息体给文件名 → `filename_from_disposition` 从下载响应头 Content-Disposition 取)、`media_dir` 每次顺带清理 7 天过期下载。Telegram `document` 按扩展名分流(图片类走视觉、其余走文件)。飞书 webhook 与长连接共用 `save_image_resource`/`save_file_resource`。下载失败/超限**整段不抛**、只记日志并跳过该条
- **CLI `milu gateway`**(`cli/app.py` `_cmd_gateway` + `cli/builder.py` `build_gateway_pool`):按已配置凭证**自动探测启用**渠道(或 `--channel a,b`),默认 mode=auto。`build_gateway_pool` 用**自定义 agent_factory**(仿 Web `_make_agent_factory`):每用户 Agent **工作区按 user_id 隔离** + sandbox/workspace_jail/knowledge/trace 从分层配置派生;`load_config()` 已对 `multiuser=strict` 应用覆盖(docker+jail),**把「from_llm 默认工厂不自动套 strict」的生产坑堵进产品默认**。启动横幅打印渠道/回调路径/隔离状态(docker 未就绪红字告警)+ / 命令开关与管理员数。**网关配置走 config.json `gateway` 分节(`commands`/`admins`)+ CLI 旗标 + env**(隔离仍读既有 sandbox/multiuser 配置)
- 测试 `tests/test_{gateway,gateway_commands,feishu,telegram,wechat_kf}.py`(HTTP 全走 `httpx.ASGITransport`/`MockTransport` 进程内,无真实网络/凭证)
## 关键设计约束(多用户并发 / 无状态化)
这是近期重构的核心,改动相关代码前必读 `serving/pool.py` 顶部的长注释:
- **Agent 含实例级共享状态**(`history`、`session`、`_mcp_manager`、`tools`、断连/限流重试计数等),多用户**不能共享同一个 Agent**。唯一安全方案是 **per-user Agent**(`AgentPool` 即为此而生)。瓶颈是 MCP 子进程内存(每 Agent 3-5 个 server 占 15-50 MB),不是 Agent 本身
- **todo 工具与 subagent 已无状态化**:不再用模块级单例/闭包变量,而是 Agent 在 `run()` 入口通过 **ContextVar** 注入 per-call 状态(`todo_write._current_session_dir` 注入 session 目录、`todo_write._current_plan_items` 注入内存计划、`subagent._current_subagent_events` 注入事件列表、`subagent._current_parent_mode` 注入父模式),实现 asyncio 任务级隔离。新增任何"跨调用共享"的工具状态时,沿用 ContextVar 模式,**切勿用模块级全局变量**
- **todo 计划存储双后端**(已与 session 解耦):有 session → 文件后端 `{session_dir}/plan.json`(持久化、per-user 天然隔离);无 session → 内存后端(`_current_plan_items` ContextVar,同一 Agent 跨轮保留、进程退出即弃)。因此 `session_enabled=False`(含子代理、用户自管 history)时 todo 也能用,不再抛 `RuntimeError`。LLM 通过 `todo_read` 主动拉取
- **分层配置体系**(`src/milu/config.py` 为单一真相源):可调参数统一走「**CLI 参数 > 用户 `~/.milu/config.json` > 项目 `config/milu.json` > 代码内 dataclass 默认值**」四级优先级。`MiluConfig` 嵌套分节 `agent`(含 `mode`/`session_enabled`/`llm` 模型对象/运行限额)/`compact`/`pool`/`scheduler`/`knowledge`/`default_models`(仅查看参考);`_builtin_defaults()` **从现有 dataclass 派生**基线(`AgentConfig`/`CompactConfig`/`AgentPoolConfig`),**默认值在代码里只有一份**,config.json 只承载覆盖。`load_config()` 做分层深合并。**库纯净性**:配置只在应用/CLI 入口(`build_agent` / `AgentPool.from_llm`)显式加载下传,**不侵入** `Agent` / 裸 `AgentPool(...)` 构造——直接 `Agent(llm)` 仍走 dataclass 默认,单测 hermetic。**职责分离**:`.env` 只放密钥(`{PROVIDER}_API_KEY`、搜索后端 Key)与进程级开关(`MILU_HOME`/`MILU_PROJECT_DIR`/`MCP_CONFIG_PATH` 等),可调参数全部在 config.json;config.json 旧 `api_keys` 字段已废弃(加载时忽略 + 一次性告警)。`config set agent.max_turns 50` 按当前值类型转换、稀疏写入用户级文件
- **目录策略:写数据 vs 读配置分离,均与裸 CWD 解耦**(`resources.py` 顶部注释为单一真相源):**写数据**(会话日志、记忆、CLI 配置)锚定 `user_data_dir()`(默认 `~/.milu`,`MILU_HOME` 覆盖);**读配置**(`mcp_servers.json`、`.env` 等项目自带配置)锚定 `project_dir()`(默认 CWD,`MILU_PROJECT_DIR` 覆盖),项目级找不到再回退用户级。新增「写状态」目录走 `user_data_dir()`、新增「读配置」走 `project_dir()`,**切勿在代码里写裸相对路径 `./xxx`**(作为库被集成时 CWD 漂移)
- **agent 工作区(`Agent(workspace=...)` + `resources.workspace_dir/_current_workspace`)**:agent **产出文件的落点**,避免污染 milu 启动目录/项目。`file_read`/`file_write` 的**相对路径**与**沙箱执行 CWD** 都锚定到工作区;**绝对路径不受影响**(用户/LLM 仍可显式读写他处)。注入与 sandbox 同款 ContextVar——`run()` 入口仅在本 Agent 显式设了 workspace 时 `set`(None 不覆盖)→ **子代理沿 Context 继承父工作区**(coder 写文件也落同一处);裸 `Agent(workspace=None)`/单测不注入 → 相对路径回退进程 CWD(hermetic,行为不变)。提示词 `main/coder` 经 `{{workspace}}` 变量告知模型工作区路径。默认位置 `user_data_dir()/workspace`(`MILU_WORKSPACE` 或 config `agent.workspace` 覆盖);**CLI 单人**用根目录、**Web 多用户**按 `workspace/{safe_user_id}` 隔离(`_safe_user_id` 对纯点号组件 `.`/`..` 回退 default 防目录穿越)。⚠️ `local` 后端的 `python_repl` 是进程内 exec、无法安全切 CWD,其相对路径仍按进程目录(已知局限;默认 subprocess 后端的子进程 CWD 可独立设为工作区;`file_write` 工具在工具层解析故不受影响)
- **部署策略 `multiuser`(`normal`/`strict`,config 顶层键,默认 `normal`;与单次运行的 `agent.mode` 正交)**:把"多用户严格隔离"散落的多个开关**打包成一个部署档**,一键切换、不漏配。`strict` 经 `_strict_overrides()` 作为**最高层强制覆盖**(`内置默认 ← 项目 config ← 用户 config ← strict`)——保证 `sandbox.backend=docker`(代码进容器)+ `agent.workspace_jail=true`(文件工具关进工作区)+ docker 断网这三个安全键**不被配置文件架空**(这是关键修复:全量模板 `config/milu.json` 显式含 `backend=subprocess`/`workspace_jail=false`,若 strict 在其下会被反盖回去)。strict 只强制这三个键,其余键(模型/限额/`docker_image` 等)仍按配置。故 strict 必须能用 docker;daemon 未就绪时启动横幅经 `docker_available_sync()` 探测并红色告警。无法用 docker 又想部分隔离:用 `normal` + 手动 `sandbox.backend=subprocess` + `agent.workspace_jail=true`。切换:`milu config set multiuser strict`。**两块缺一不可才是真隔离**:docker 关住 `python_repl`/`shell_command`(subprocess 下 python 仍能 `open()` 读宿主文件,只有容器能挡)+ 工作区围栏关住 `file_read`/`file_write`/`doc_read`/`image_read`(它们永远在宿主进程内跑、docker 管不到)。**工作区围栏 `agent.workspace_jail`**(`Agent(workspace_jail=...)` + `_current_workspace_jail` ContextVar,与 workspace 同款继承):开启后四个文件工具把路径 `_resolve_path` 后经 `_jail_violation` 校验"最终绝对路径在工作区内",越界(逃逸的绝对路径/`..`)拒绝;普通单人留 false(保留绝对路径读项目的便利)
- **memory 长期记忆为用户级存储、单开关启用**(`memory_tool.py`):**默认关闭**——`Agent(memory=False)`(默认)不注册工具不注入提示词;`memory=True` 启用(身份 `"default"`);`memory="user_id"` 启用并按用户隔离。存储与 session **解耦**:`~/.milu/memory/{user_id}.json`(`MILU_HOME` 可覆盖),同一标识跨 session、跨进程共享。启用时记忆条目**每轮渲染进 system prompt 末尾**(`render_memory_prompt`,每轮重读文件),记忆文件路径在 `run()` 入口经 ContextVar `_current_memory_path` 注入(未启用注入 None,子代理不会继承父路径误写)。AgentPool 默认工厂把 `agent_kwargs={"memory": True}` 派生为 `memory=user_id`(防全部用户共享一份 default 记忆)。条目 {content, category, created_at},上限 200 条丢弃最旧,内容级去重。与对话历史互补——历史会被压缩截断,记忆条目始终完整
- **knowledge 向量知识库为用户级存储、单开关启用**(`src/milu/knowledge/` + `tools/builtin/knowledge_tool.py`):**默认关闭**——`Agent(knowledge=False)`(默认);`True` → 启用(身份 `"default"`);`"user_id"` → 按用户隔离;`KnowledgeConfig` → 程序化定制(embedding 厂商/模型/分块参数)。与 memory 互补:memory 少量条目全量进 prompt,knowledge 大语料分块向量化按需召回(适合 FAQ/手册/笔记等**非结构化语义检索**;代码/结构化文档用 file_read+grep 式检索更合适)。**三工具拆分**(参数正交性):`kb_search`(纯读 is_safe=True,结果经 `min_score` 相似度阈值过滤——默认 0.35,低于阈值返回「无相关内容」而非把垃圾片段喂给 LLM 诱导幻觉;阈值依据见 benchmark 正负分离度)/ `kb_ingest`(入库,is_safe=False,复用 doc_tool 解析栈,pdf 按页窗口循环绕过单次截断,同名 source 整体替换;**入口独立过 selfguard**——直接读文件不走 file_read,须自查防旁路)/ `kb_manage`(action=list/stats/delete/clear,safe_check 判 list/stats 只读)。**存储**(`~/.milu/knowledge/{safe_user_id}/`):chunks.jsonl + vectors.npy + meta.json(embedding 模型指纹,错配早失败防向量空间混存),全量重写 + mkstemp+fsync+os.replace 原子替换,meta 最后写作提交点;进程内 per-目录 asyncio.Lock,跨进程不保护(与 scheduler 同档取舍)。**检索**:numpy 暴力余弦(numpy 自 0.1.0 起为核心依赖,因知识库默认开启;store 内仍延迟 import),`load()` 带 mtime 签名内存缓存(文件未变不重读盘、返回共享对象勿原地改,跨进程写入签名变化自动失效),轻量定位万级 chunks——查询延迟由 embedding API 网络往返主导,ANN 在此量级无收益;超 5-10 万块/需元数据过滤/多进程写时在 KnowledgeStore 接口后换 sqlite-vec 等后端,工具层不动。**Embedder 单类 + 静态表**(不做抽象基类注册表):走各厂商 OpenAI 兼容 `/embeddings` 端点,密钥复用 `{PROVIDER}_API_KEY`,批次间 Semaphore 并发(默认 4 路,大文档入库吞吐受 API 往返延迟主导);embedding 厂商独立于对话模型(claude/deepseek/kimi 无 embedding API,配置成它们时给改配指引),生命周期跟随 Agent(`close_knowledge()` 在 `__aexit__`/池淘汰路径调用)。`KnowledgeRuntime` 在 `run()` 入口经 ContextVar `_current_knowledge` 注入(未启用注入 None,子代理不继承)。**检索路由(来源目录常驻 prompt)**:启用时来源清单 + 路由规则**每轮渲染进 system prompt**(`render_knowledge_prompt`,与技能"元数据常驻、正文按需加载"同思路)——模型知道库里有什么才会主动 kb_search 而非放任 LLM 内置联网搜索抢答;规则三要素:覆盖主题必须先查库、回答区分「内部知识库/网络搜索」出处、无果再回退其他工具。来源清单经 `list_sources()` mtime 缓存(文件未变不读盘),上限 50 个来源防撑爆 prompt,渲染失败返回空串不阻断对话。**前置自动检索 `knowledge.auto_retrieve`(默认开)**:启用后 `run()` 入口每轮用用户消息自动 embedding+检索(`prepare_auto_context`),达标片段作为「本轮自动检索」块附在知识库段落末尾——不依赖模型决策永不漏检,未命中只留一行提示(帮模型判断内部资料不覆盖);失败只记日志不阻断对话;代价每轮 +1 次 embedding(~200ms),适合知识库为核心场景的部署。**之所以敢默认开**:`prepare_auto_context` 先查 `store.is_empty()`(只读 chunks.jsonl 计数、不碰 numpy)空库即早退——未入库用户零 embedding 调用、零开销,仅真正入过库者(已配好 embedding key + numpy)才付每轮成本。**双阈值设计**:自动注入用独立的 `auto_top_k`(默认 3)/`auto_min_score`(默认 0.5),比 kb_search 的 `top_k`/`min_score`(5/0.35)更紧——自动路径高精度省上下文,边缘命中由模型经 kb_search 捞回不损召回;kb_search 的 top_k 另有硬上限 8(钳制 LLM 传 10/20 的挥霍)。⚠️ embedding 余弦分正确命中通常 0.4~0.76,阈值勿设 0.8+(会拒掉一切)。config.json 各分节可用 `"//"` 键写说明(加载时安全忽略)。AgentPool 默认工厂把 `agent_kwargs={"knowledge": True}` 派生为 user_id(与 memory 同款 opt-in 派生);CLI/Web 服务由 config.json `knowledge.enabled`(默认 true)控制启用
## 代码风格约定
- 中文 docstring 和注释,中文 git commit message
- 使用 Python 3.10+ 语法:`str | None` 联合类型、`match/case`
- 数据模型全部用 `@dataclass`,不可变模型加 `frozen=True`
- 环境变量命名:`{PROVIDER}_API_KEY`(如 `QWEN_API_KEY`, `ANTHROPIC_API_KEY`)
- 无 lint/type-check 工具配置(未启用 ruff、mypy、black)
## 测试模式
- 单元测试使用 `unittest.mock.AsyncMock` mock LLM 响应;`conftest.py` 提供 `mock_openai_client` fixture
- 每个 provider 有独立测试文件 `test_<provider>.py`;MCP 测试在 `tests/test_mcp/`
- 并发/隔离专项测试:`test_concurrency_with_pool.py`、`test_concurrency_stress.py`、`test_agent_pool.py`、`test_subagent_concurrent_isolation.py`、`test_todo_concurrent_isolation.py`(验证无状态化的核心保证)
- 真实 API 测试在 `test_real_api.py` / `test_real_new_providers.py`,需要 `.env` 配置
- 知识库全面评测:`.venv/Scripts/python tests/benchmark_knowledge.py`(走真实 embedding API,五维度:检索质量 Hit@k/MRR/关键词召回对照 bigram 基线、双阈值评估(自动注入率/注入精度/回捞带/负例拦截)、上下文成本(钳制收益)、性能(并发加速 A/B、冷热缓存)、工具层端到端;自动生成 Markdown 报告到 `docs/知识库评测报告.md`,带达标判定;无 `test_` 前缀不被 pytest 收集)
- pytest 配置:`asyncio_mode = "auto"`(`pyproject.toml`),所有 async 测试无需 `@pytest.mark.asyncio`
## git提交规则
- **重要**:所有自动的git提交的主题都用英文,下面的描述内容使用中文Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

