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 中新增"流式输出原则"章节。