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

@@ -79,20 +79,26 @@ tools:
被调时你会收到:
- args: 查询参数
- cache_data: 上次缓存的原始数据(有
- cache_data: 上次缓存的原始数据(有则传,无则 null
有 cache_data 且未过期 → 直接回答,不调 API
有 cache_data 且未过期 → 直接回答,不使用 http-get
无 cache_data → 调 http-get 获取新数据。
返回格式:
你的回答文本
---CACHE---(只有数据更新时带)
{原始 JSON 数据}
返回格式(两段式)
---RESULT---
{结构化 JSON 数据,如 {"temp": 25, "condition": "晴"}}
---TEXT---
你想要对用户说的文本
```
**返回协议说明**
- `---RESULT---`:原始数据,进缓存,不进 dialog 上下文
- `---TEXT---`:陈述文本,进 dialog 上下文,由 dialog 用自己的风格说出去
- 如果本次没有新数据(比如 cache 命中后直接复述),可不带 `---RESULT---`
## Session 规范
- 文件路径:`~/.config/yunshu/session.json`
- 文件路径:`~/.config/yunshu/session/session.json`
- 格式JSON 数组,元素为 Message 对象(兼容 OpenAI Chat Completion messages 格式)
- 角色类型:`system`, `user`, `assistant`, `tool`
- 启动时清空,每轮对话追加
@@ -101,10 +107,57 @@ tools:
## 工具注册规范
- 工具在 `tool.go``init()` 中通过 `RegisterTool()` 注册
- 每个工具定义Name, Description, ParametersJSON Schema, Execute 函数
- 工具在 `tool.go``init()` 中通过 `RegisterTool()` + `NewTool[T]()` 注册
- `NewTool[T any](name, desc string, fn func(T) (string, error))` 泛型函数自动处理一切:
-`toolschema.go``structToSchema` + `typeToSchema`)反射推导 JSON Schema
- JSON 往返桥接:`map[string]any` ↔ 类型安全的输入结构体
- handler 内直接访问结构体字段,无需 `args["x"].(string)` 类型断言
- 注册路径:`tool.go:init()``NewTool[T]()``RegisterTool()`
- 工具名与 `.md` 文件中声明的 tools 列表对应
- Execute 函数接收 `map[string]interface{}` 参数,返回 string 和 error
- 新增工具三步定义输入结构体struct tags: json, description, enum→ 写 handler → 调用 `NewTool[T]()`
## 日志系统
日志系统分两层:**控制台输出**charmbracelet/log → stderr和**文件持久化**YAML 序列 → `log.yml`)。
### 日志写入
```go
warnLog(msg string, keyvals ...any) // 写 log.yml + 可选 stderr
errorLog(msg string, keyvals ...any) // 同上
infoLog(msg string, keyvals ...any) // 同上
```
- stderr 输出受 `logToStderr` 全局开关控制(交互模式默认关,`/log on` 开启)
- `log.yml` 始终写入YAML 序列格式,每次读→追加→重写
### 日志查看
`yunshu log` 子命令:
```
yunshu log → 全量显示(时间倒序)
yunshu log --top N → 只看最后 N 条
yunshu log --level warn → 过滤级别
yunshu log --clear → 清空
yunshu log --watch → 监听模式2s 轮询)
```
### 日志点
```
[INFO] task(weather) 开始
[INFO] 子 Agent 开始 agent=weather
[INFO] LLM 调用完成 tokens=1420 duration=3.2s
[WARN] 解析 session.json 失败 err=...
```
关键原则:
- 成功日志用 `infoLog`task 开始/完成、LLM 调用完成、会话清空)
- 异常日志用 `warnLog`(解析失败、文件不存在的异常错误)
- 错误日志用 `errorLog`(工具执行失败、子 Agent 未找到)
---
## 环境变量
@@ -115,7 +168,7 @@ tools:
| `LLM_MODEL` | 否 | 模型名,覆盖配置文件 |
| `OPENAI_API_KEY` | 否 | 兼容旧名,当 `LLM_API_KEY` 未设置时生效 |
> *注:可在 `~/.config/yunshu/config.yaml` 中配置,无需环境变量。
> *注:可在 `~/.config/yunshu/config.yml` 中配置,无需环境变量。
> 首次使用请运行 `yunshu onboard` 交互式初始化。
---
@@ -175,3 +228,29 @@ tools:
4. **Cache 设计原则**Frontmatter 声明 `cache.keys``cache.ttl``task` 工具机械化从 args 取值拼 key、查/写缓存。子 Agent 不感知缓存存在,只需在回答末尾可选带 `---CACHE---` + JSON 供 task 存储。一个 Agent 一个缓存 JSON 文件MD5 hash 做 key。
5. **记忆系统规则**:共享黑板模式,所有 Agent 可读,仅 memory Agent 可写。dialog-agent 是最小写入者(只写 `dialog_context`memory Agent 负责从对话中提取用户画像写入长期记忆。
6. **子 Agent 返回协议 `---RESULT---` / `---TEXT---` 双段设计**:子 Agent 回答分两段——RESULT 是原始结构化数据(进缓存,不进 dialog 上下文TEXT 是陈述文本(进 dialog 上下文,由 dialog 用自己的风格输出)。这样 dialog 不会产生"复述感",子 Agent 的数据也保持干净。
7. **两次 LLM 调用架构**:用户提问 → dialog 的 LLM 判断 → `task` 工具调子 Agent 的 LLM第一次→ 子 Agent 返回 TEXT → dialog 的 LLM 用自己的风格说出去(第二次)。各司其职,职责单一。
8. **`task` 工具内部调用 `RunSubAgent()` 实现子 Agent 执行**`RunSubAgent` 不读写全局 session.json子 Agent 的 tool_calls 用完即毁,不污染主上下文。返回后由 `task` 工具解析 `---RESULT---` / `---TEXT---`
### 2026-05-16
1. **charmbracelet/log v2`charm.land/log/v2`)用作结构化日志**。输出到 stderr 时自动带时间戳和级别前缀彩色渲染。v2 模块路径改为 `charm.land/log/v2`(非 `github.com/charmbracelet/log/v2`)。`log.NewWithOptions(w, opt)` 创建实例,方法签名:`log.Warn(msg, keyvals...)``log.Error(msg, keyvals...)``log.Info(msg, keyvals...)`
2. **log.yml 使用 YAML 序列追加策略**。每次写入先读(反序列化为 `[]logEntry`)→ 追加 → 重写(`yaml.Marshal`)。不是 JSONL不是纯文本。适用于 CLI 工具的日志量级。`readLogs()` 返回全部条目用于 `yunshu log` 展示。
3. **`RunSubAgent``maxToolCalls=2` 安全上限**。子 Agent 的 LLM 循环中如果连续 2 轮都在调工具,第 3 轮强制返回 `---TEXT---\n子 Agent 执行轮次超限,已终止)`。防止 prompt 设计缺陷导致子 Agent 死循环。
4. **单 Agent 查询性能优化**。dialog-agent 的 prompt 明确指示:对于只需调一个子 Agent 的查询,跳过 observation 写入和 summary 写入,直接输出子 Agent 结果。综合查询的 observation + summary 合并在同一轮 memory.write 调用。实测 note 保存从 ~90s 降至 ~10s。
5. **路径安全使用 `filepath.EvalSymlinks` + `filepath.Rel` 三段校验**Clean → Join → EvalSymlinks → Rel。防止符号链接绕过路径限制。仅 `strings.Contains("..")` 不够,因为符号链接可以指向 ConfigDir 之外。`os.IsNotExist` 时跳过 EvalSymlinks新文件创建场景
6. **`flag.ExitOnError` 用于 `yunshu log` 子命令 flag 解析**。遇到未知 flag 自动 exit(2) 并打印用法,比手动处理 flag 更简洁。与 `main.go` 的主 args 手动解析配合不冲突。
7. **流式输出使用 SSE 协议OpenAI 兼容格式**。请求加 `"stream": true`,响应逐行 `data: {...}`,以 `data: [DONE]` 结尾。用 `bufio.Reader.ReadString('\n')` 逐行读取,不走 `io.ReadAll`。流式和非流式模式下 tool_calls 的表现不同:非流式一次返回完,流式按 index 分片到达,需要 `accumulatedToolCall` 累积合并。
8. **按 `\n\n` 段落边界缓冲后经 mdprint 渲染**。流式 token 是碎片,不能直接过 mdprint后者需要完整 Markdown parse → AST → ANSI。解决方案将内容写入 blockBuf`\n\n` 处切分——之前的是完整 block调用 `mdprint.Print` 渲染到 stdout之后的是不完整残段留在 buffer 继续累积。流结束时 `mdprint.Print(blockBuf)` 刷最后一段。实测 weather 场景每个 section标题/表格/分隔线)依次弹出,延迟 1-3s渲染效果与非流式一致。
9. **流式模式下 model 可能先吐文本再吐 tool_call**。非流式 API 的 model 要么返回 content 要么返回 tool_calls不会同时出现。但流式模式下model 可能先输出一段"思考"文字,然后才决定调工具。这段文字已经显示给用户,之后工具返回又有第二轮 LLM 响应,就造成了"用户看到两段回复"的问题。**修复方式prompt 层面要求 model"先调工具,不要先说话"**,在 dialog-agent.md 中新增"流式输出原则"章节。

View File

@@ -60,56 +60,114 @@ pkg/
└── termui/ 终端交互(行输入、模式设置)
```
## 当前 tools
## 项目文件
| 工具名 | 作用 | 实现 |
|--------|------|------|
| http-get | HTTP GET 请求 | Go |
| skill | 按需加载知识 | Go |
| geocode | 城市名 → 坐标 | Go调 wttr.in |
| read-file | 读取文件 | Go |
```
main.go CLI 入口onboard/help/version/log 子命令)
types.go 核心类型AgentDef, Schema, ToolDef, Message…
loader.go .md 解析frontmatter + body
catalog.go CatalogAgent 生成 + tools.yml 输出
registry.go AgentRegistryScanAgents, GetMain, GetSub…
llm.go LLM API 封装(豆包/OpenAIsync.Once 延迟加载)
tool.go 工具注册 + safeMemoryPath + ExecuteTool + 7 个工具 handler
toolschema.go 泛型+反射工具注册NewTool[T], structToSchema
runtime.go RunAgent + RunSubAgentmaxToolCalls=2+ cache + session
logger.go charmbracelet/log v2 全局实例(→ stderr
log.go 双写 wrapperwarnLog/errorLog/infoLog+ log.yml + yunshu log 命令
```
## 当前 tools
| 工具名 | 作用 | 实现 |
|--------|------|------|
| http-get | HTTP GET 请求 | Go |
| skill | 按需加载知识 | Go |
| geocode | 城市名 → 坐标 | Go(调 wttr.in |
| read-file | 读取文件 | Go |
| task | 调度子 Agent含缓存管理 | Go阶段一新增 |
| memory.read | 读长期记忆 | Go阶段一新增 |
| memory.write | 写长期记忆 | Go阶段一新增 |
| 工具名 | 作用 | 注册方式 |
|--------|------|---------|
| http-get | HTTP GET 请求 | `NewTool[HTTPGetInput]` |
| skill | 按需加载知识 | `NewTool[SkillInput]` |
| geocode | 城市名 → 坐标(调 wttr.in | `NewTool[GeocodeInput]` |
| read-file | 读取文件 | `NewTool[ReadFileInput]` |
| task | 调度子 Agent含缓存管理 + 多步骤编排) | `NewTool[TaskInput]` |
| memory.read | 读取 config/session/notes 等记忆文件 | `NewTool[MemoryReadInput]` |
| memory.write | 写入记忆文件(.md 按 ## 标题合并,.yml 按 key 合并) | `NewTool[MemoryWriteInput]` |
## 后续演进
所有工具都通过 `NewTool[T]` 泛型函数注册,输入结构体自动反射生成 JSON Schemahandler 内参数为类型安全的结构体字段。
## 核心流程
### 当前(单 Agent
```
yunshu (三层分离+单agent)
└─ weather-agent.md (type: main既是入口也是天气专家)
用户输入
RunAgent → CallLLMStream (SSE 流式,\n\n 段落缓冲 → mdprint 渲染)
├─ 流内容到达 → tryFlushBlocks 检测 \n\n
│ ├─ 完整 block → mdprint.Print 渲染到 stdout
│ └─ 残段 → 留在 blockBuf 继续缓冲
├─ 流结束 → mdprint.Print(blockBuf) 刷残段
├─ 返回 tool_calls累积重建→ 继续循环
│ ├─ task(weather/train/hotel/…) → RunSubAgent → TEXT → 回对话
│ │ ├── maxToolCalls=2 兜底
│ │ └── 每步写 infoLog/warnLog → log.yml
│ ├─ task(profile) → 提取用户画像写入 config/user.md
│ ├─ task(note) → 保存/查询笔记 (notes.md / notes/*.md)
│ ├─ memory.read → 读 config/user.md / session/dialog.yml / soul.md / notes.md
│ ├─ memory.write → 写 config/ session/ notes/ 各文件
│ └─ 其他工具 (http-get, geocode, …)
└─ 返回 text → 流已渲染完毕 → 结束
单 Agent 查询跳过 observation + summary综合查询合并同一轮写
```
### 阶段一(会议室架构基础
## 当前状态2026-05-16 v2.3.0
```
yunshu (会议室架构)
├── dialog-agent.md (type: main入口+调度)
├── weather-sub.md (type: sub天气领域)
├── memory-sub.md (type: sub记忆管理)
── narrator-sub.md (type: sub汇报员,成熟期)
yunshu (会议室架构 — 核心引擎 + 日志 + 画像 + 备忘录)
├── dialog-agent.md (type: main主持者)
├── weather-sub.md (type: sub天气) ✅
├── profile-sub.md (type: sub用户画像) ✅
── note-sub.md (type: sub备忘录) ✅
├── ✨ 日志系统 ✅ charmbracelet/log v2 + log.yml + yunshu log
├── ✨ LLM 延迟加载 ✅ sync.Once--help 不读 config
├── ✨ 路径安全 ✅ EvalSymlinks + filepath.Rel
├── ✨ 热加载 ✅ 交互模式每轮 ScanAgents()
├── ✨ 会话裁剪 ✅ LoadSession 限 40 条
├── ✨ 流式输出 ✅ SSE 流式 + \n\n 段落缓冲 + mdprint
├── ✨ 性能优化 ✅ note 一步完成 + obs/summary 合并 + maxToolCalls=2
├── earthquake-sub.md (type: sub地震) ❌ 待实现
├── train-sub.md (type: sub火车票) ❌ 待实现
├── hotel-sub.md (type: sub住宿) ❌ 待实现
└── narrator-sub.md (type: sub汇报员/成熟期) ❌ 待实现
```
### 阶段二(多领域扩展)→ 河虾 Claw
## 存储结构
```
yunshu / hxclaw (多领域主-从)
├── dialog-agent.md (type: main入口+调度)
├── weather-sub.md (type: sub天气)
├── earthquake-sub.md (type: sub地震)
── volcano-sub.md (type: sub火山)
├── nuclear-sub.md (type: sub核电监测)
├── memory-sub.md (type: sub记忆)
└── narrator-sub.md (type: sub汇报)
~/.config/yunshu/
├── config/
├── config.yml ← LLM 配置(已有)
├── soul.md ← AI 灵魂(用户可编辑)
── user.md ← 用户画像profile-sub 写 ## 画像dialog 写 ## AI观察到
├── session/
├── session.json ← 完整对话历史(直接 POST API
└── dialog.yml ← 对话摘要dialog 每轮写入)
├── notes.md ← 备忘录note-sub 维护,列表格式)
├── notes/ ← 独立笔记文件(复杂内容用)
├── log.yml ← YAML 序列日志yunshu log 命令查看)
├── cache/ ← 子 Agent 缓存
├── agents/ ← Agent 定义
├── skills/ ← 知识技能
└── memory.json ← ❌ 已删除(迁移完毕)
```
## 写入策略
| 文件 | 写入方式 | 说明 |
|------|---------|------|
| `config/user.md` | `##` 标题合并 | 各板块独立更新,互不覆盖 |
| `session/dialog.yml` | key 合并 | 每轮覆写对话摘要 |
| `notes.md` | 全文覆写 | note-sub 全量管理 |
| `notes/` 独立文件 | 全文覆写 | 每个笔记一个文件 |
| `log.yml` | YAML 序列追加 | 读→追加→Marshal→写双写模式 |
```
## 架构文档
详细架构计划见 `docs/会议室架构计划书.md`
- `docs/会议室架构计划书.md` — 完整设计方案
- `docs/AGENTS.md` — 编码规范
- `docs/changelog.md` — 变更日志
- `docs/taolun.md` — 讨论历史

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`:添加实现状态标记、多步骤编排章节、泛型注册章节
---
### 架构规划:会议室模式

View File

@@ -329,3 +329,38 @@ Markdown 渲染器完成 AST 解析后,需要确定标题的终端展示风格
| 记忆 | 无 | 共享黑板Memory Agent 管写入 |
| 工具污染 | 所有工具混在一起 | 按角色过滤 |
| 失败影响 | 一个坏 tool_call 可能污染全部 | 子 Agent 用完即毁 |
---
## 2026-05-16 多步骤编排与文档更新
### 背景
用户提出真正想要的场景是"我要去北京" → 主 Agent 自动理解需要查天气、交通、住宿,依次调不同子 Agent综合回答。当前 runtime 用 `capturedOutput` 覆写机制,子 Agent 结果被截走,主 Agent 没机会继续推理。
### 讨论:跨 Agent 数据传递
用户提出opencode 可以在同上下文中切换不同 agent 继续工作YunShu 是否也可以?
经过分析,结论:
- `task` 工具调用子 Agent → 子 Agent 返回 TEXT → 普通工具响应,可以留对话上下文
- 主 Agent 看到结果后,可以决定再调另一个子 Agent也可以直接回答
- **唯一需要改的**:砍掉 `capturedOutput` 覆写,让 LLM 的自然循环接管
### 决定:移除 capturedOutput启用多步骤编排
`runtime.go` 去掉约 5 行代码(`capturedOutput` 变量、覆写逻辑),然后:
- 主 Agent 的 LLM 可以连续调多个子 Agentweather → train → hotel
- 数据在步骤间通过对话上下文自然传递
- prompt 告诉 LLM信息不够就继续调够了就综合回答
- 单步骤查询只要天气行为不变——LLM 按指令直接输出子 Agent 结果
### 同步更新文档
| 文档 | 更新内容 |
|------|---------|
| `docs/architecture.md` | 工具列表、核心流程图、当前状态树 |
| `docs/会议室架构计划书.md` | 实现状态标记、多步骤编排章节、文件结构、调用示例 |
| `docs/AGENTS.md` | 工具注册规范改为 `NewTool[T]` 方式 |
| `docs/changelog.md` | 新增 2.0.0 版本日志 |
| `docs/taolun.md` | 追加本次讨论 |

View File

@@ -1,8 +1,18 @@
# 云枢·Agent 会议室架构计划书
> **生成日期**2026-05-11
> **最后更新**2026-05-16
> **目的**:从单 Agent 架构升级为"会议室模式"1 主持 + N 领域专家 + 共享黑板)
> **最终目标**:在云枢上验证通过后,移植到 HxClaw河虾 Claw
>
> **实现状态**
> - ✅ 核心引擎registry + runtime + cache + session
> - ✅ 7 个工具task, memory.read/write, http-get, skill, read-file, geocode
> - ✅ 泛型+反射工具注册NewTool[T]
> - ✅ 多步骤编排(主 Agent 连续调多个子 Agent
> - ✅ weather-sub.md 天气子 Agent
> - ❌ memory-sub.md 记忆管理员子 Agent
> - ❌ earthquake / train / hotel 等扩展子 Agent
---
@@ -11,21 +21,27 @@
```
用户
┌──────▼──────────────────────────────────────┐
│ 主持者dialog-agenttype: main │
│ 人格 + 调度规则 + task + memory 工具 │
│ 唯一入口,用户只和它对话 │
└──────┬───────────────────────────────────────┘
│ task("weather", {city: "北京"})
│ task("earthquake", {region: "通州"})
task("memory", {action: "read", ...})
┌───────────────────────────────────────────┐
发言人(领域子 Agenttype: sub │
weather / earthquake / memory / narrator │
被调才说话,返回文本 + 可选缓存数据 │
各自的 cache / skills / tools 互相隔离 │
───────────────────────────────────────────
┌──────▼──────────────────────────────────────────
│ 主持者dialog-agenttype: main
│ 人格 + 调度规则 + task + memory 工具
│ 唯一入口,用户只和它对话
│ ✨ 可以连续多次调不同子 Agent综合数据后回答 │
└──────┬───────────────────────────────────────────┘
│ task("weather", {city: "北京"}) ← 第一步
← 北京明天 5°C 晴
│ task("train", {city: "北京", date: "明天"}) ← 第二步(看到天气后决定)
│ ← G102 08:00 ¥680
│ task("hotel", {city: "北京", nights: 3}) ← 第三步
│ ← 建国饭店 ¥500/晚
│ → 综合: "明天北京5°C…G102早8点…建国饭店…" ← 最终回答
┌───────────────────────────────────────────────
│ 发言人(领域子 Agenttype: sub │
│ weather / earthquake / memory / narrator │
│ 被调才说话,返回文本 + 可选缓存数据 │
│ 各自的 cache / skills / tools 互相隔离 │
│ 不感知其他子 Agent 存在,结果由主 Agent 整合 │
└───────────────────────────────────────────────┘
│ 读写
┌───────────────────────────────────────────┐
@@ -45,7 +61,9 @@
**职责**
- 用户的唯一入口
- 有血有肉的个人助理,能闲聊
- 识别用户意图,调度对应的子 Agent
- 识别用户意图,**可以连续多次**调度不同的子 Agent
- 每次 task() 返回后,决定继续调下一个还是综合回答(多步骤编排)
- 把上一步子 Agent 的结果作为上下文,传递给下一次 task() 的参数
- 读/写记忆(用户画像、上下文摘要)
**工具列表**
@@ -205,7 +223,26 @@ task(agent_name, arguments)
- `raw` 存原始参数,方便调试和遍历
- 每次读缓存时惰性清理过期条目
### 3.3 传给子 Agent 的参数
### 3.3 子 Agent 返回协议
子 Agent 返回分两段,由 `task` 工具解析:
```
---RESULT---
{结构化 JSON 数据(进缓存,不进 dialog 上下文)}
---TEXT---
子 Agent 想要对用户说的陈述文本(进 dialog 上下文)
```
- `---RESULT---`:原始 API 数据task 写入缓存文件,**不传给 dialog**
- `---TEXT---`:子 Agent 已经组织好的陈述文本task 返回给 dialog 的 LLM
**为什么分成两段**
- RESULT 保持子 Agent 的领域数据干净,不进主上下文
- TEXT 给 dialog 一个"素材"dialog 用自己的语气说出来,不会产生"复述感"
- 如果子 Agent 这次没有更新数据(比如 cache 命中后直接回答),可以只带 `---TEXT---`
### 3.4 传给子 Agent 的参数
```json
{
@@ -288,30 +325,28 @@ task(agent_name, arguments)
```
yunshu/
├── main.go # CLI 入口
├── types.go # 核心类型AgentDef, ToolDef 等
├── types.go # 核心类型AgentDef, Schema, ToolDef, Message…
├── loader.go # .md 解析Frontmatter + Body
├── catalog.go # CatalogAgent 生成 + tools.yml 输出
├── registry.go # Agent 注册中心(扫描 + 按 type 分类)
├── llm.go # LLM API 封装
├── tool.go # 工具注册 + ExecuteTool
├── runtime.go # RunAgent 主循环
├── tool.go # 工具注册 + 7 个工具 handler
├── toolschema.go # 泛型+反射 Schema 生成NewTool[T], structToSchema
├── runtime.go # RunAgent + RunSubAgent + cache + session
├── agents/
│ ├── dialog-agent.md # type: main — 主持者
│ ├── weather-sub.md # type: sub — 天气
│ ├── earthquake-sub.md # type: sub — 地震(预留)
│ ├── memory-sub.md # type: sub — 记忆管理员
│ └── narrator-sub.md # type: sub — 汇报员(成熟期)
│ ├── weather-sub.md # type: sub — 天气
│ ├── earthquake-sub.md # type: sub — 地震(预留)
│ ├── memory-sub.md # type: sub — 记忆管理员
│ └── narrator-sub.md # type: sub — 汇报员(成熟期)
├── skills/
│ ├── msn-weather-api/SKILL.md
│ └── geocoding/SKILL.md
├── docs/
── taolun.md
│ ├── 会议室架构计划书.md
│ ├── changelog.md
│ ├── architecture.md
│ └── AGENTS.md
── 此目录
└── pkg/
├── mdprint/
@@ -323,12 +358,16 @@ yunshu/
```
~/.config/yunshu/
├── config.yaml # LLM 配置
├── session.json # 对话历史(仅 user ↔ dialog
├── agents/
── dialog-agent.md # 用户可覆盖对话 Agent
│ └── weather-sub.md # 用户可覆盖天气子 Agent
├── skills/ # 用户可扩展知识
├── config/
│ ├── config.yml # LLM 配置
│ ├── user.md # 用户画像(## 画像 / ## AI观察到
── soul.md # AI 灵魂(用户可编辑)
├── session/
├── session.json # 对话历史(仅 user ↔ dialog
│ └── dialog.yml # 对话摘要(每轮覆写)
├── notes.md # 备忘录列表
├── notes/ # 独立笔记文件
├── log.yml # API 异常记录
├── cache/
│ ├── weather.json
│ ├── earthquake.json
@@ -342,6 +381,8 @@ yunshu/
## 六、调用流程示例
### 6.1 单子 Agent 查询
```
用户: "北京明天多少度?"
@@ -351,66 +392,121 @@ yunshu/
3. 调 LLMsession + system + tools
4. LLM 返回 tool_call: task("weather", {city: "北京", forecast_type: "tomorrow"})
task 工具:
task 工具(子 Agent 调用):
1. 加载 weather-sub.md Frontmatter
→ cache.keys: ["city", "forecast_type"], ttl: 7200
2. 拼 key → "city=北京&forecast_type=tomorrow" → hash
3. 查 weather.json → MISS(首次查明天)
4. 调子 Agent LLM:
→ cache.keys: ["city", "forecast_type"], ttl: 1800
2. 拼 key → "city=北京&forecast_type=tomorrow" → sha256[:6]
3. 查 cache/weather.json → MISS
4. 调子 Agent LLMRunSubAgent隔离的循环
system = weather-sub.md
user = {args: {city: "北京", forecast_type: "tomorrow"}, cache_data: null}
5. 子 Agent:
├── geocode("北京") → (39.9, 116.4)
5. 子 Agent 工具链:
├── skill("msn-weather-api") → 接口参数
├── http-get(URL) → JSON
── 返回: "北京明天 18-31°C晴"
---CACHE---
{temp_lo: 18, temp_hi: 31, condition: "晴"}
6. task 提取 CACHE → 写 weather.json
7. 返回 "北京明天 18-31°C晴" 给 dialog
├── geocode("北京") → (39.9, 116.4)
── http-get(URL) → JSON
└── 返回:
---RESULT---
{temp: {lo:18, hi:31}, condition: "晴"}
---TEXT---
▪ 北京明天天气
...
6. task 提取 RESULT → 写 cache/weather.json
7. 返回 TEXT 给 HOST
HOSTruntime.go:
1. tool 返回 → LLM 继续
2. LLM 生成最终回答:
"北京明天 18到31度大晴天适合出去浪"
3. dialog: task("memory", {action: "update_context", agent: "weather", city: "北京"})
4. 追加 session.json
5. 输出给用户
1. tool 结果 → 追加到对话 → LLM 再次推理
2. LLM 根据 prompt 指令"子 Agent 输出就是答案"→ 直接输出 TEXT
3. 追加 session.json
4. 显示给用户
```
### 6.2 多步骤编排(新增能力)
```
用户: "去北京出差,明天走,待三天"
HOSTruntime.go:
1. 加载 dialog-agent.md → system prompt
2. 读 session → 恢复上下文
3. 调 LLMsession + system + tools
┌─ 第 1 轮 LLM 推理 ──────────────────────────────┐
│ LLM 决定: 先查天气 │
│ tool_call: task("weather", {city:"北京", │
│ forecast_type:"tomorrow"})
│ → 子 Agent 返回: 北京明天 5°C 晴 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 2 轮 LLM 推理 ──────────────────────────────┐
│ LLM 看到天气结果, 决定查火车票 │
│ tool_call: task("train", {city:"北京", date:"明天"})│
│ → 子 Agent 返回: G102 08:00 ¥680 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 3 轮 LLM 推理 ──────────────────────────────┐
│ LLM 看到天气+车次, 决定查酒店 │
│ tool_call: task("hotel", {city:"北京", nights:3}) │
│ → 子 Agent 返回: 建国饭店 ¥500/晚 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 4 轮 LLM 推理 ──────────────────────────────┐
│ LLM 觉得信息够了 → 返回文本 │
│ "明天北京5°C记得带外套。G102早8点¥680。 │
│ 建国饭店3晚¥1500。总预算约¥2180。" │
│ → 追加 session.json → 显示给用户 │
└──────────────────────────────────────────────────┘
```
---
## 七、实施阶段
## 七、实施阶段 — 当前状态
### 阶段一:基础架构(当前 → 1周
### 阶段一:基础架构(已完成,超计划完成
| 任务 | 说明 |
|------|------|
| 1.1 Frontmatter 扩展 | 解析 `type: main\|sub``cache` 字段 |
| 1.2 Agent 注册中心 | `registry.go` 扫描 `agents/``~/.config/yunshu/agents/`,按 type 分类 |
| 1.3 `task` 工具 | 实现子 Agent 加载、LLM 调用、缓存读写 |
| 1.4 Cache 系统 | `cache/` 目录管理、JSON 文件读写、过期清理 |
| 1.5 `memory.read/write` 工具 | 简单的 JSON 文件读写 |
| 1.6 dialog-agent.md | 重写为主持者(极薄:人格 + 调度规则) |
| 1.7 weather-sub.md | 从旧 weather-agent.md 改造 |
| 步骤 | 文件 | 状态 | 说明 |
|------|------|------|------|
| 1.1 | `types.go` | ✅ | `AgentDef``Type string``Cache *CacheDef``Schema` 替代 `ToolParameter` |
| 1.2 | `loader.go` | ✅ | Frontmatter 解析加 `type``cache` 字段 |
| 1.3 | `registry.go` | ✅ | `ScanAgents()` 扫描按 type 分类,同名覆盖 |
| 1.4 | `tool.go` | ✅ | `task` / `memory.read` / `memory.write` + 4 个原有工具 |
| 1.5 | `runtime.go` | ✅ | `RunSubAgent` + `RunAgent` + cache + session |
| 1.6 | `toolschema.go` | ✅ ✨ | **新增(计划外)** — 泛型+反射 `NewTool[T]` 替代手写 Schema |
| 1.7 | `main.go` | ✅ | `ScanAgents().GetMain("dialog")` 动态注入子 Agent 列表 |
| 1.8 | `agents/dialog-agent.md` | ✅ | 主持者,含多步骤编排指令 |
| 1.9 | `agents/weather-sub.md` | ✅ | 天气子 AgentMarkdown 输出 + 生活建议 |
| 1.10 | — | ✅ ✨ | **多步骤编排(计划外)** — 砍掉 `capturedOutput`,主 Agent 连续调多个子 Agent |
#### 计划外新增内容
### 阶段二:记忆系统(阶段一完成后)
1. **泛型+反射工具注册**`toolschema.go`
- `NewTool[T any]()` 泛型构造函数,自动反射推导 JSON Schema
- 输入结构体 + struct tags → 零模板代码的工具注册
- handler 内参数为类型安全的结构体字段,无需 `args["x"].(string)`
| 任务 | 说明 |
|------|------|
| 2.1 memory-sub.md | 记忆管理员 Agent从对话提取画像 |
| 2.2 记忆数据库 | 结构化存储(画像、偏好、异常记录) |
| 2.3 画像自动提取 | memory Agent 定期从对话中提取有用信息 |
2. **多步骤编排**`runtime.go` 改造):
- `capturedOutput` 覆写机制已移除
- 子 Agent 结果作为普通工具响应留在对话上下文
- LLM 可以连续多次调 `task()`,直到信息收集完毕再回答
### 阶段二:记忆系统(待开始)
### 阶段三:扩展(可选)
| 任务 | 文件 | 状态 |
|------|------|------|
| 2.1 memory-sub.md | 记忆管理员 Agent从对话提取画像 | ❌ |
| 2.2 记忆数据库 | 结构化存储(画像、偏好、异常记录) | ❌ |
| 2.3 画像自动提取 | memory Agent 定期从对话中提取有用信息 | ❌ |
| 任务 | 说明 |
|------|------|
| 3.1 earthquake-sub | 地震信息查询 |
| 3.2 narrator-sub | 个性化回答生成 |
| 3.3 更多数据源 | 台风、核电、火山... |
### 阶段三:扩展(待开始)
| 任务 | 说明 | 状态 |
|------|------|------|
| 3.1 earthquake-sub | 地震信息查询 | ❌ |
| 3.2 train-sub | 火车票查询 | ❌ |
| 3.3 hotel-sub | 住宿查询 | ❌ |
| 3.4 narrator-sub | 个性化回答生成(成熟期) | ❌ |
---