feat: v2.3.0 流式输出 + 日志系统 + 会议室架构全面升级

- 流式输出: SSE 逐 token 接收, \\n\n\ 段落缓冲后 mdprint 彩色渲染
- 日志系统: charmbracelet/log v2 双写(stderr + log.yml), yunshu log 命令
- 会议室架构: dialog(main) + weather/profile/note(sub) 多 Agent 编排
- 泛型工具注册: NewTool[T] 反射推导 JSON Schema, 类型安全
- 安全加固: safeMemoryPath 三段校验(EvalSymlinks+Rel), maxToolCalls=2
- 性能优化: sync.Once 延迟加载, note 一步完成, obs/summary 合并
- Prompt 适配: 流式输出原则(先调工具不说话), 单 Agent 查询跳过 obs+summary
- 文档: AGENTS.md + architecture.md + changelog.md 全部同步至 v2.3.0
This commit is contained in:
titor
2026-05-16 17:21:29 +08:00
parent 0898188086
commit c4a0e3ef53
24 changed files with 2769 additions and 338 deletions

View File

@@ -2,7 +2,265 @@
> 坐看云卷云舒,静听花开花落
## [2.0.0-planning] - 2026-05-11
## [2.3.0] - 2026-05-16
### 日志系统 + 性能优化 + 安全加固
#### 日志系统
引入 `charmbracelet/log` v2 作为结构化日志库,取代零散的 `log.Printf` / `fmt.Fprintln(os.Stderr, ...)`
| 组件 | 说明 |
|------|------|
| `logger.go` | charmbracelet/log 全局实例,输出到 stderr |
| `log.go` | log.yml 持久化YAML 序列追加)、双写 wrapper、`yunshu log` 命令 |
日志流向:
```
warnLog/errorLog/infoLog
├─ logToStderr ? → charmbracelet/log → stderr交互模式默认关闭/log on 开启)
└─ 始终写入 → appendLog → log.ymlYAML 序列,位于 ~/.config/yunshu/log.yml
```
新增日志点:
```
[INFO] task(weather) 开始
[INFO] 子 Agent 开始 agent=weather
[INFO] LLM 调用完成 tokens=1420 duration=3.2s
[INFO] task(weather) 完成
[WARN] 解析缓存失败 agent=weather err=...
```
`yunshu log` 子命令:
```
yunshu log → 全量显示(时间倒序)
yunshu log --top N → 只看最后 N 条
yunshu log --level warn → 过滤级别
yunshu log --clear → 清空
yunshu log --watch → 监听模式2s 轮询)
```
#### 流式输出
LLM 响应改为 SSE 流式输出,按 `\n\n` 段落边界缓冲后经 mdprint 渲染到 stdout
| 组件 | 文件 | 说明 |
|------|------|------|
| SSE 类型 | `llm.go` | `sseChunk`/`sseDelta`/`sseToolCallDelta` 等 |
| `CallLLMStream` | `llm.go` | 请求加 `stream: true``bufio.Reader` 逐行解析 |
| 段落缓冲 | `llm.go` | `blockBuf` + `tryFlushBlocks`:检测最后一个 `\n\n`,完成块过 mdprint残段续传 |
| 流结束刷残段 | `llm.go` | 末尾 `mdprint.Print(blockBuf)` 兜底 |
| 重建响应 | `llm.go` | tool_call 按 index 累积 + SSE 碎片合并 |
关键设计:
```
LLM SSE → blockBuf += content
tryFlushBlocks:
├─ 有 \n\n → 之前的部分 mdprint.Print(complete)
│ → 剩余部分留在 blockBuf
└─ 无 \n\n → stay
流结束 → mdprint.Print(blockBuf)
```
Prompt 适配(`agents/dialog-agent.md`
- 新增"流式输出原则":先调工具,不要先说话
- 调度表 `回应 → task(profile)` 改为 `静默调 task拿到结果后再回应`
#### 性能优化
| 优化 | 改动 | 效果 |
|------|------|------|
| note-sub 一步完成 | prompt 收紧read→write→立即返回 | note 保存从 ~50s 降至 ~10s |
| dialog 合并 obs+summary | 单 Agent 查询跳过观察和摘要;综合查询合并为一轮 | 节省 ~30s |
| maxToolCalls=2 | RunSubAgent 超限兜底 | 防止死循环 |
#### 代码加固
| 改动 | 文件 | 说明 |
|------|------|------|
| LLM 延迟加载 | `llm.go` | `init()``sync.Once``--help` 不再读 config |
| 路径安全检查 | `tool.go` | `safeMemoryPath()`Clean + EvalSymlinks + Rel 三段校验 |
| 静默错误 → warnLog | 8 处 across runtime/tool/registry | 所有 `json.Unmarshal` / `yaml.Unmarshal` 吞掉的错误改为结构化日志 |
| 对话记忆修剪 | `runtime.go` | `LoadSession` 只返回最近 40 条消息 |
| Onboard 补齐 | `onboard.go` | `ensureUserConfig()` 创建 user.md/soul.md/notes/ |
| 热加载 | `main.go` | 交互模式每轮 `ScanAgents()`,新增 agent 即时生效 |
#### UX 改进
- 交互模式默认不显示日志(`/log on` 开启)
- `--help` 移除环境变量章节,增加 `log` 命令说明
- 单次查询模式保留日志显示
---
## [2.2.0] - 2026-05-16
### 存储重组session 目录 + .yml 统一后缀
#### 目录结构调整
| 旧路径 | 新路径 | 原因 |
|--------|--------|------|
| `session.json` | `session/session.json` | 对话会话文件归入 session 目录 |
| `context/dialog.yaml` | `session/dialog.yml` | context 语义模糊,与 session 合并;.yml 后缀 |
| `config.yaml` | `config.yml` | 统一后缀 |
| `log.yaml` | `log.yml` | 统一后缀 |
| `context/` | 已删除 | 文件移至 session/ |
所有 yaml 文件统一使用 `.yml` 后缀。配置文件、对话摘要、日志文件全部一致性调整。
#### 代码改动
| 文件 | 改动 |
|------|------|
| `runtime.go` | `sessionPath()` 改为 `session/session.json` |
| `config.go` | `LoadConfig`/`SaveConfig` 读写 `config.yml` |
| `onboard.go` | 提示信息改为 `config.yml` |
| `main.go` | 新增 `migrateFilePaths()` 处理所有旧路径迁移 |
| `tool.go` | 描述字符串更新 `context/``session/``.yaml``.yml` |
| `agents/dialog-agent.md` | 所有 `context/dialog.yaml``session/dialog.yml` |
| `docs/` | 存储树、写入策略、编码规范全部同步更新 |
#### 迁移逻辑
`migrateFilePaths()` 在启动时自动处理:
- `config.yaml` → 复制到 `config.yml`,删除旧文件
- `session.json` → 复制到 `session/session.json`,删除旧文件
- `context/dialog.yaml` → 复制到 `session/dialog.yml`,删除旧文件及空目录
- `log.yaml` → 复制到 `log.yml`,删除旧文件
所有迁移仅在目标文件不存在时执行,支持幂等。
---
## [2.1.0] - 2026-05-16
### 用户画像 + 备忘录系统
#### 拆分 memory.json
旧的 `memory.json` 一锅烩,现在拆成**按用途分文件**
| 旧文件 | 新文件 | 格式 | 维护者 |
|--------|--------|------|--------|
| `memory.json["personality"]` | `config/soul.md` | Markdown | 用户手动编辑 |
| `memory.json["dialog_context"]` | `context/dialog.yaml` | YAML | dialog 每轮写入 |
| `memory.json["agent_errors"]` | `log.yaml` | YAML | 系统追加 |
| (不存在) | `config/user.md` | Markdown | profile-sub 维护 |
| (不存在) | `notes.md` + `notes/*.md` | Markdown | note-sub 维护 |
迁移逻辑在 `main.go:migrateMemoryJSON()`,启动时自动检测并迁移,确认完成后再删除 `memory.json`
#### 新增子 Agent
| Agent | 文件 | 用途 |
|-------|------|------|
| profile-sub | `agents/profile-sub.md` | 从对话中提取用户画像,增量合并到 `config/user.md` |
| note-sub | `agents/note-sub.md` | 笔记管理。默认 `notes.md` 列表,复杂内容可存为 `notes/{name}.md` |
#### memory.read/write 工具改造
从旧的 flat JSON key-value 改为路径路由:
- `.md` 文件 → 全文覆写(`memory.write("config/user.md", markdown_str)`
- `.yaml` 文件 → 合并写入(`memory.write("context/dialog.yaml", {topic, last_agent})`
- 目录 → 返回文件列表
- 安全检查:拦截 `..` 遍历和绝对路径
- 目录自动创建
#### dialog-agent.md 更新
- 新增调度规则:检测用户透露个人信息 → 调 `task("profile", ...)` 提取画像
- 新增调度规则:检测"帮我记住" → 调 `task("note", ...)` 存备忘录
- 对话摘要改为写入 `context/dialog.yaml`,而非旧 `memory.json["dialog_context"]`
- 用户画像改为读取 `config/user.md`
- 新增观察规则:每轮回复后向 `## AI观察到` 段写入语气/情绪/性格观察
#### heading-aware merge
`memory.write``.md` 文件由全文覆写改为按 `##` 标题合并:
- `memory.write("config/user.md", "## 画像\n...")` 只替换 `## 画像` 段,`## AI观察到` 段不受影响
- 同一 writer 可多次写入同一标题,每次覆盖该段内容
- 不同 writer 写不同标题,互不干扰
- 底层函数 `mdMerge()``##` 拆分 → 标题匹配 → 重组
#### profile-sub.md 更新
- 改为写 `## 画像` 段(不再写整篇 user.md
- 文档规范化:给出 `## 画像` 格式示例
- 强调不破坏其他段(`## AI观察到` 等)
---
### 发布会:会议室架构核心引擎就绪
**架构规划:会议室模式**(阶段一全部完成,超计划交付)
#### 核心引擎
| 组件 | 文件 | 说明 |
|------|------|------|
| Agent 注册中心 | `registry.go` | `ScanAgents()``type`(main/sub) 分类,用户目录覆盖 |
| 子 Agent 隔离运行 | `runtime.go`: `RunSubAgent` | 隔离 LLM 循环,返回 `---RESULT---`/`---TEXT---` |
| 主 Agent 循环 | `runtime.go`: `RunAgent` | session 持久化 + mdprint 渲染 |
| 缓存系统 | `runtime.go`: cache 辅助 | SHA256[:6] 拼 key惰性过期 |
| 工具目录生成 | `catalog.go` | `GenerateToolsYAML``BuildSubAgentPrompt` |
#### 新增工具
| 工具 | 注册方式 | 说明 |
|------|---------|------|
| `task` | `NewTool[TaskInput]` | 调度子 Agent + 缓存管理 |
| `memory.read` | `NewTool[MemoryReadInput]` | 读 `~/.config/yunshu/memory.json` |
| `memory.write` | `NewTool[MemoryWriteInput]` | 写长期记忆value 支持任意 JSON 类型 |
#### 实际子 Agent
| Agent | 文件 | 状态 |
|-------|------|------|
| dialog-agent.md | `agents/dialog-agent.md` | 主持者type:main含多步骤编排指令 |
| weather-sub.md | `agents/weather-sub.md` | 天气子 Agenttype:subMarkdown 排版 + 生活建议 |
### ✨ 计划外新增
#### 泛型 + 反射工具注册(`toolschema.go`
受 Charmbracelet/Fantasy 启发,引入 `NewTool[T any]()` 泛型构造函数:
- 输入结构体 `json`/`description`/`enum` tags → 自动反射生成 JSON Schema
- 消除 ~120 行手写 Schema 模板代码(旧的 `ToolParameter`/`ToolProperty` 类型已删除)
- handler 内参数为类型安全的结构体字段,无需 `args["x"].(string)` 类型断言
- 支持嵌套结构体、slice、map、基础类型、interface{} 类型
#### 多步骤编排runtime 改造)
移除 `capturedOutput` 覆写机制,子 Agent 结果作为普通工具响应留在对话上下文中:
- 主 Agent 可以连续多次调 `task()`weather → train → hotel
- 每次返回后 LLM 继续推理,决定下一步
- 信息收集完毕再综合回答,不再被 `capturedOutput` 截断
- 单步骤查询如纯天气行为不变LLM 按 prompt 指令直接输出子 Agent 结果
#### 其他变更
- `http-get.headers` 从 JSON 字符串改为 `map[string]string`LLM 直接传对象
- `memory.write.value``string` 改为 `interface{}`,直接存储任意 JSON 值
- `types.go`:删除旧的 `ToolParameter`/`ToolProperty` 结构体,新增 `Schema(map[string]any)` 类型
- `catalog.go``buildToolList` 适配新版 Schema
- `agents/dialog-agent.md`:加入多步骤编排指令 + 数据传递说明
### 文档更新
- `docs/architecture.md`:更新工具列表、核心流程、当前状态
- `docs/AGENTS.md`:工具注册规范更新为 `NewTool[T]` 方式
- `docs/会议室架构计划书.md`:添加实现状态标记、多步骤编排章节、泛型注册章节
---
### 架构规划:会议室模式