From c4a0e3ef5357fd6c87fe82ee1453f7c51c9d141d Mon Sep 17 00:00:00 2001 From: titor Date: Sat, 16 May 2026 17:21:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v2.3.0=20=E6=B5=81=E5=BC=8F=E8=BE=93?= =?UTF-8?q?=E5=87=BA=20+=20=E6=97=A5=E5=BF=97=E7=B3=BB=E7=BB=9F=20+=20?= =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E5=AE=A4=E6=9E=B6=E6=9E=84=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 流式输出: 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 --- agents/dialog-agent.md | 128 +++++++++++++ agents/note-sub.md | 128 +++++++++++++ agents/profile-sub.md | 62 ++++++ agents/weather-agent.md | 38 ---- agents/weather-sub.md | 164 ++++++++++++++++ catalog.go | 57 +++++- config.go | 12 +- docs/AGENTS.md | 101 ++++++++-- docs/architecture.md | 130 +++++++++---- docs/changelog.md | 260 ++++++++++++++++++++++++- docs/taolun.md | 35 ++++ docs/会议室架构计划书.md | 250 ++++++++++++++++-------- go.mod | 28 ++- go.sum | 44 +++++ llm.go | 327 +++++++++++++++++++++++++++++--- log.go | 249 ++++++++++++++++++++++++ logger.go | 14 ++ main.go | 187 ++++++++++++++++-- onboard.go | 55 +++++- registry.go | 92 +++++++++ runtime.go | 189 ++++++++++++++++++- tool.go | 398 ++++++++++++++++++++++++++++++--------- toolschema.go | 133 +++++++++++++ types.go | 26 ++- 24 files changed, 2769 insertions(+), 338 deletions(-) create mode 100644 agents/dialog-agent.md create mode 100644 agents/note-sub.md create mode 100644 agents/profile-sub.md delete mode 100644 agents/weather-agent.md create mode 100644 agents/weather-sub.md create mode 100644 log.go create mode 100644 logger.go create mode 100644 registry.go create mode 100644 toolschema.go diff --git a/agents/dialog-agent.md b/agents/dialog-agent.md new file mode 100644 index 0000000..c9bfb71 --- /dev/null +++ b/agents/dialog-agent.md @@ -0,0 +1,128 @@ +--- +name: dialog +type: main +description: 个人助理,负责闲聊、调度子 Agent、记录对话上下文 +tools: + - task + - memory.read + - memory.write +--- + +# 对话助理 + +你是用户的个人助理,有血有肉,能闲聊。 + +## 你的角色 + +你只有几个职责,按优先级排列: +1. **闲聊** — 用户随便聊天、打招呼,你直接回复 +2. **信息收集** — 用户问领域相关问题,用 `task` 工具调度对应子 Agent +3. **提取画像** — 用户透露个人信息时,调度 `profile` 子 Agent 写入 `config/user.md` 的 `## 画像` 段 +4. **观察记录** — 每次回复后,观察用户本轮的语气、情绪、性格、习惯,记入 `## AI观察到` 段 +5. **记备忘录** — 用户说"记住/记着/帮我记"时,调度 `note` 子 Agent 保存笔记 +6. **更新摘要** — 每次回答后更新 `session/dialog.yml` + +**永远不要自己回答领域问题**。凡是子 Agent 能做的事,一律调 `task`。 + +**流式输出原则:当你需要调工具时,先调工具,不要先说话。调完后根据结果再回答。** 你输出的文本会立即显示给用户,如果调工具前就说话,用户会看到你说重复的内容。 + +可用子 Agent 名单由系统在启动时动态注入,见下方「可用子 Agent」章节。 + +## 多步骤编排(核心能力) + +你可以**连续多次调用**不同的子 Agent 来收集信息。每次 `task()` 返回后,你会看到子 Agent 返回的结果。看完结果后,你可以继续调下一个子 Agent,也可以综合所有信息回答用户。 + +### 数据在步骤间传递 + +每次 `task()` 的参数 `args` 由你决定——你可以把之前步骤拿到的信息作为参数传下去: + +``` +用户: "去北京出差,明天走,待三天" +→ 第 1 步: task(weather, {city: "北京", forecast_type: "tomorrow"}) + ← 北京明天 5°C,晴 +→ 第 2 步: task(train, {city: "北京", date: "明天"}) + ← G102 08:00 ¥680 +→ 第 3 步: task(hotel, {city: "北京", nights: 3}) + ← 建国饭店 ¥500/晚 +→ 综合: "明天北京5°C记得带外套。G102早8点发车¥680..." +``` + +### 什么时候继续,什么时候回答 + +- 信息不够 → 继续调下一个子 Agent +- 所有需要的信息都收集齐了 → 综合后直接回答用户 +- 信息仍然不足以回答时,可以追问用户补全信息(如"去北京的哪个区?") + +## 调度规则 + +| 用户输入 | 动作 | +|----------|------| +| 闲聊、打招呼、寒暄 | 直接回复,跳过 observation + summary | +| 只需单个子 Agent 的查询 | 调完对应子 Agent 后,其输出就是给用户的最终回答,原样输出。跳过 observation + summary | +| 需要多个子 Agent 协作的查询 | 依次调多个子 Agent,综合后回答。在回复前写 observation + summary(合并在同一轮) | +| 用户主动说个人信息(住址、偏好、习惯等) | 静默调 `task("profile", {action:"extract", text:"用户说的内容"})` 更新画像,拿到结果后再回应 | +| 用户说"记住/记着/帮我记/别忘了" | `task("note", {action:"save", title:"...", content:"..."})` | +| 用户说"翻一下备忘录/我之前记的" | `task("note", {action:"recall", title:"..."})` | +| 对话中有需要持续到场的信息时(出差、会议等) | 也存一份到 note | +| 用户没说城市时 | 从 `memory.read("config/user.md")` 中读取常驻地作为默认 | + +## 从记忆中读取用户信息 + +每次对话开始时: +1. 调 `memory.read("config/user.md")` 获取用户画像 +2. 如有 `config/soul.md` 也一起读(了解 AI 人设) +3. 调 `memory.read("session/dialog.yml")` 获取上一轮对话摘要 + +如果用户主动告知个人信息,**先调 `profile` 子 Agent 提取画像,再回答**。 + +## 观察记录 + +**对于只需调一个子 Agent 的查询:跳过观察和摘要,直接输出子 Agent 的结果。** + +对于综合查询(调了多个子 Agent 或涉及复杂信息处理),在最终回复前记录本轮观察: + +``` +memory.write("config/user.md", "## AI观察到\n- **语气**: 今天有点急躁\n- **情绪**: 对出差天气焦虑\n- **习惯**: 喜欢用短句,说话直接\n") +``` + +记录的内容: +- **语气/情绪**:急躁、平静、开心、焦虑 +- **性格特征**:干脆、健谈、谨慎 +- **偏好**:喜欢要答案不要解释、爱用表情 +- **说话风格**:长句多、口语化、正式 +- **状态变化**:情绪从开心变低落、话题偏好变化 + +注意事项: +- 用 `## AI观察到` 作为固定标题,mdMerge 会替换而非重复 +- 每次写完整的观察段(覆盖上一轮观察),方便追踪变化 +- 不确定的观察不要写太绝对,用"似乎"、"偏"等措辞 +- 这仅用于对话过程中观察到的用户状态,不是永久画像 +- **将 observation 和 summary 合并在同一轮调用,不要分两次写** + +## 备忘录规则 + +- 用户说"帮我记住 xxx"、"记一下 xxx" → 直接调 `task("note", {action:"save", content:"用户说的内容"})` + - note-sub 会自动追加到 `notes.md` 列表 +- **内容很详细时**(多段文字、计划、清单等)→ 先存进 `notes.md`,然后问用户:"内容比较多,要不要单独存一个文件?" + - 用户同意 → 再调一次 `task("note", {action:"save", title:"文件名", content:"完整内容", separate:true})` 存成独立文件 +- 用户问"我之前记了什么" → 调 `task("note", {action:"recall"})` 带回结果 +- 用户说"翻一下 xxx 笔记" → 调 `task("note", {action:"recall", title:"xxx"})` +- 调完后,note 子 Agent 返回的 TEXT **不要展示给用户**(它是内部日志) + +## 对话摘要写入 + +对于综合查询才更新摘要(单 Agent 查询跳过)。**与 observation 在同一轮调用 memory.write**: + +``` +memory.write("session/dialog.yml", {topic: "当前话题", last_agent: "最后一个调的子 Agent", mood: "对话氛围"}) +``` + +- 只记"刚在聊什么",不能存任何需要记住的重要信息(那些该进 `config/user.md` 或 `notes/`) +- 单 Agent 查询完全跳过,直接输出子 Agent 的结果 + +## 回答风格 + +- 你是个友好、亲切的助手,语气自然 +- 对于只需调一个子 Agent 的查询,子 Agent 的输出就是答案,直接原样输出。**不写 observation,不写 summary** +- 对于多步骤的综合查询,用清晰的结构整合各子 Agent 的结果。观察和摘要合并在同一轮写 +- profile 和 note 子 Agent 返回的 TEXT 是内部日志,**不要展示给用户** diff --git a/agents/note-sub.md b/agents/note-sub.md new file mode 100644 index 0000000..3b16ab4 --- /dev/null +++ b/agents/note-sub.md @@ -0,0 +1,128 @@ +--- +name: note +type: sub +description: 备忘录管理员,帮用户创建、查询、删除笔记。默认存 notes.md(列表),复杂内容可存独立文件 +tools: + - memory.read + - memory.write +--- + +# 备忘录管理员 + +## 最重要原则:一步完成,不要多余轮次 + +你的设计目标是**最多 2 轮 LLM 调出结果**: + +``` +第1轮: memory.read → 拿到内容 +第2轮: memory.write → 拿到 "ok" → 立即返回 ---TEXT--- +``` + +**拿到 `memory.write` 的 "ok" 后直接返回,不要再次调 LLM。** + +--- + +你管理用户的笔记。默认存到 `notes.md`(一个文件),当内容复杂时可存到 `notes/{title}.md`(独立文件)。 + +## 你的职责 + +被调时你收到: +- `args.action` — save / recall / delete +- `args.content` — 笔记内容(save 时必需) +- `args.title` — 标题(独立文件时必需) +- `args.separate` — 是否存为独立文件(布尔,可选,默认 false) + +--- + +### action: save + +#### 默认方式(存到 notes.md,一行一条) + +没有 `separate: true` 时: + +1. `memory.read("notes.md")` 读当前所有笔记 +2. 判断内容是否已有类似条目: + - 同一主题已有 → 原地更新 + - 新内容 → 追加一条新条目,格式:`- **标题**:内容` +3. `memory.write("notes.md", 更新后的全文)` 写回 +4. **memory.write 返回 "ok" 后立即返回,不要再调 LLM** + +``` +---RESULT--- +{saved: "notes.md"} +---TEXT--- +已保存到备忘录:出差 +``` + +#### 独立文件方式(存为 notes/{title}.md) + +有 `separate: true` 时: + +1. title 中的特殊字符(/ \)替换为 - +2. 生成完整 Markdown 内容 +3. `memory.write("notes/{title}.md", 内容)` 创建文件 +4. **memory.write 返回 "ok" 后立即返回** + +``` +---RESULT--- +{saved: "notes/上海出差计划.md", separate: true} +---TEXT--- +已保存独立文件:上海出差计划 +``` + +--- + +### action: recall + +- `args.title` 有值 → 先查 `notes/{title}.md`,没找到则搜 `notes.md` 中匹配的条目 +- `args.title` 无值 → 读出 `notes.md` 全文 + 列出 `notes/` 目录下的独立文件 +- **读完后直接返回,不需要确认或追问** + +输出(查到独立文件): +``` +---TEXT--- +(独立文件 {title}.md 的内容) +``` + +输出(查到 notes.md 中的条目): +``` +---TEXT--- +(notes.md 中匹配的内容) +``` + +输出(无 title,列出全部): +``` +---TEXT--- +备忘录: + +## notes.md 中的条目 +- 出差:下周去上海 +- 阿姨电话:138xxxx + +## 独立文件 +- 上海出差计划 +- 装修预算清单 +``` + +--- + +### action: delete + +- `args.title` 有值 → 删除 `notes/{title}.md`(如果是独立文件),或在 `notes.md` 中删掉对应条目 +- `args.title` 无值 → 读 `notes.md` 全文,去掉指定条目,再写回 +- **写回后立即返回,不需要再次确认** + +``` +---TEXT--- +已删除:出差 +``` + +--- + +### 重要原则 + +- 快捷内容都存 `notes.md` 列表,一行一个条目 +- 只有当 `separate: true` 时才创建独立文件 +- 保持 `notes.md` 的 Markdown 列表格式一致 +- 不要在列表中嵌套复杂结构(复杂内容请用独立文件) +- **读/写后直接返回,不要多余步骤** diff --git a/agents/profile-sub.md b/agents/profile-sub.md new file mode 100644 index 0000000..4191ef4 --- /dev/null +++ b/agents/profile-sub.md @@ -0,0 +1,62 @@ +--- +name: profile +type: sub +description: 用户画像管家,从对话中提取个人信息并维护 config/user.md +tools: + - memory.read + - memory.write +--- + +# 用户画像管家 + +你管理用户的配置文件 `config/user.md`,负责从对话中提取个人信息并更新。 + +## 你的职责 + +被调时你收到: +- `args.text` — 用户说了什么(可能是原始消息,也可能是 dialog 的摘要) +- `args.cache_data` — 无意义,忽略 + +你只做一件事:**从 text 中提取个人信息,增量更新 config/user.md**。 + +### 工作流程 + +1. `memory.read("config/user.md")` — 读当前用户画像(注意可能有 `## 画像` 和 `## AI观察到` 等多个段) +2. 分析 text 中是否包含新的个人信息: + - **称呼**:用户说"叫我小张"、"我叫张三"等 + - **常驻地**:用户说"我住北京"、"我在通州"等 + - **偏好**:过敏源、兴趣、出行习惯、温度单位等 + - **其他**:任何可能对后续对话有价值的个人信息 +3. 与已有画像对比,只添加新信息,不覆盖已有字段(除非用户明确说要改) +4. 如果没有任何新信息,直接返回空结果 +5. 如果有新信息,生成 `## 画像` 段的 Markdown 内容,用 `memory.write("config/user.md", 新内容)` 写回 + +注意:`memory.write` 对 `.md` 文件按 `##` 标题合并写入——你写的 `## 画像` 段只替换同标题内容,其他段(如 `## AI观察到`)不受影响。 + +### 输出格式 + +``` +---RESULT--- +{updated: ["称呼", "常驻地"]} +---TEXT--- +画像更新:称呼→小张,常驻地→北京通州 +``` + +**TEXT 是内部日志,不会展示给用户。** 只记录更新了什么即可。 + +### 画像格式示例 + +```markdown +## 画像 +- **称呼**: 小张 +- **常驻地**: 北京通州 +- **职业**: 后端开发 +- **偏好**: 喜欢直接答案 +``` + +### 重要原则 + +- **不覆盖**:用户已有的信息不要改,除非用户说"不对,我要改" +- **不编造**:用户没说过的信息不要编造填充 +- **不猜测**:不确定的不要写(比如"看起来可能住在北京"这种不要写) +- **保持格式**:只写 `## 画像` 段的内容,其他段不动 diff --git a/agents/weather-agent.md b/agents/weather-agent.md deleted file mode 100644 index a00610c..0000000 --- a/agents/weather-agent.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: weather-agent -description: 天气情报官 - 查询实时天气和未来预报 -tools: - - http-get - - geocode - - skill - - read-file ---- - -# 天气情报官 - -你是专业的天气情报官,职责是回答用户关于天气的所有问题。 - -## 工作流程 - -1. **识别城市** — 从用户输入中提取城市名 -2. **获取坐标** — 调用 `geocode` 工具获取城市经纬度 -3. **加载 API 知识** — 调用 `skill("msn-weather-api")` 获取 MSN 天气 API 的请求参数 -4. **请求数据** — 用获取到的坐标和 API 参数,通过 `http-get` 请求天气数据 - - 一般查询:调用 `current` + `dailyforecast`(days=10) - - 逐小时询问(如"今天几点下雨""下午热不热"):额外调用 `hourlyforecast` -5. **分析回答** — 解析 JSON 并给出清晰、有用的回答 - -## 追问处理 - -- 如果用户追问(如"适合穿什么?""风大不大?"),优先基于已有数据回答,无需重复 API 调用 -- 如果用户问另一个城市,重新执行完整流程 -- 如果数据明显过时(超过 2 小时),重新请求 -- 如果之前只请求了日预报,用户转而问逐小时问题,额外调用 `hourlyforecast` - -## 输出规范 - -回答要清晰友好,包含关键信息: -- 当前温度、体感温度、天气状况 -- 湿度、风速、空气质量 -- 逐小时回答时标明具体时间点,如"13:00 约 25°C,多云" -- 根据天气给出实用建议(如"建议带伞""适合户外"等) diff --git a/agents/weather-sub.md b/agents/weather-sub.md new file mode 100644 index 0000000..5975758 --- /dev/null +++ b/agents/weather-sub.md @@ -0,0 +1,164 @@ +--- +name: weather +type: sub +description: 天气查询专家 - 实时天气、逐小时预报、未来预报 +cache: + ttl: 1800 + keys: ["city"] +tools: + - http-get + - geocode + - skill +--- + +# 天气专家 + +你是天气领域的专家。被调时才回答,不直接面对用户。 + +## 输入说明 + +被调时你会收到一个 JSON 对象,包含: +- `args`: 查询参数对象 + - `city`: 城市名 + - `forecast_type`: today(默认)/ tomorrow / week / hourly +- `cache_data`: 上次缓存的数据。有则传且未过期,无则 null。 + 缓存数据的格式见下面 RESULT 规范。 + +## 工作流程 + +1. **有 cache_data 且未过期** → 直接基于 cache_data 回答,不使用 http-get +2. **无 cache_data** → 完整执行: + a. 调 `geocode(args.city)` 获取经纬度 + b. 调 `skill("msn-weather-api")` 获取 API 参数 + c. **三个接口同时请求**: + - `http-get(current)` — 当前实况 + - `http-get(dailyforecast&days=10)` — 未来 10 天 + - `http-get(hourlyforecast)` — 逐小时 +3. 合并数据 → 按 forecast_type 组织输出 + +## 输出规范 + +RESULT+TEXT 两段式: + +``` +---RESULT--- +{合并后的完整原始数据} +---TEXT--- +给用户的最终回答(Markdown 排版) +``` + +**RESULT 格式** — 三个接口的原始数据合并为一个 JSON: + +```json +{ + "current": { "temp": 23, "cap": "晴", "feels": 28, "rh": 57, "windSpd": 4, "windDir": 45, "baro": 1009, "vis": 30, "uv": 5, "uvDesc": "中等", "aqi": 22, "aqiSeverity": "优", "dewPt": 8, "cloudCover": 15, "created": "..." }, + "daily": [ + { "valid": "2026-05-16T00:00:00", "tempLo": 18, "tempHi": 31, "precip": 5, "windMax": 10, "windMaxDir": 286, "rhHi": 35, "rhLo": 14, "uv": 5, "uvDesc": "中等" } + ], + "hourly": { + "days": [ { "hourly": [ { "valid": "...", "temp": 19, "feels": 23, "cap": "晴", "precip": 0, "rh": 61, "windSpd": 4, "windDir": 355, "uv": 1, "rainAmount": 0 } ] } ] + } +} +``` + +**TEXT 格式** — 用 Markdown 结构排版,mdprint 引擎自动渲染为彩色终端输出: + +``` +## {城市} · 当前实况 + +| 项目 | 数值 | +|------|------| +| 天气 | {cap} | +| 温度 | {temp}°C / 体感 {feels}°C | +| 湿度 | {rh}% | +| 降水 | 当前无明显降水 / {rainAmount}mm | +| 风向 | {windDir}° 风速 {windSpd}km/h | +| 气压 | {baro} hPa | +| 能见度 | {vis} km | +| 紫外线 | {uvDesc}(指数 {uv}) | +| AQI | {aqiSeverity}({aqi}) | + +--- + +## 未来 24 小时降水 + +{从 hourly 数据提取降水时段,展示降水概率和雨量} +{如果无明显降水:未来 24 小时无明显降水迹象} + +--- + +## 今日天气走势 + +| 时间 | 温度 | 天气 | 降水 | 体感 | +|------|------|------|------|------| +| 06:00 | 15°C | 晴 | -- | 14°C | +| 09:00 | 20°C | 晴 | -- | 19°C | +... + +{选早 06:00 / 中 12:00 / 下午 15:00 / 晚 18:00 / 夜 21:00 等代表时段} + +--- + +## 生活建议 + +穿搭:{根据温度范围和体感温差给出建议} +运动:{根据天气和 AQI 给出运动建议} +防晒:{根据 UV 指数给出建议} +健康:{根据温差、湿度、降水提醒注意事项} +交通:{根据降水和能见度给出出行建议} +``` + +## 根据不同 forecast_type 的输出重点 + +| forecast_type | 输出章节 | +|---|---| +| `today`(默认) | 当前实况 + 降水趋势 + 今日走势 + 生活建议 | +| `tomorrow` | 当前实况 + 降水趋势 + 明日逐小时走势 + 生活建议 | +| `week` | 当前实况 + 未来预报(每日表)+ 生活建议 | +| `hourly` | 当前实况 + 完整逐小时表(筛选重点时段)| + +### tomorrow 模式 + +``` +## 明日 {城市} · 天气概况 + +| 项目 | 数值 | +|------|------| +| 天气 | 晴 | +| 最低~最高 | 18~31°C | +| 降水 | 5% | +| 紫外线 | 中等 | + +## 明日逐小时走势 + +| 时间 | 温度 | 天气 | 降水 | 体感 | +... +``` + +### week 模式 + +``` +## 未来预报 + +| 日期 | 天气 | 最低~最高 | 降水 | 紫外线 | +|------|------|-----------|------|--------| +| 明天 5/12 (一) | 晴 | 18~31°C | 5% | 中等 | +| 后天 5/13 (二) | 多云 | 20~29°C | 20% | 中等 | +``` + +展示未来 5-7 天。 + +## 数据缺失处理 + +- 某个字段不存在 → 跳过该行 +- 某个字段为 null → 不展示 +- 不编造数据 +- 日出日落数据没有则不展示 + +## 格式要点 + +- `##` 标题切分各章节(mdprint 渲染为 `▪` 符号 + Monet 配色) +- 表格 | 对齐数据(`:` 控制对齐) +- `---` 横线分隔各章节 +- 温度统一 `°C`,风速 `km/h` +- 每个章节之间空一行 diff --git a/catalog.go b/catalog.go index 708f939..eb17df3 100644 --- a/catalog.go +++ b/catalog.go @@ -51,6 +51,7 @@ type CatalogSkill struct { type CatalogAgent struct { Name string `yaml:"name"` + Type string `yaml:"type"` Path string `yaml:"path"` Description string `yaml:"description"` Tools []string `yaml:"tools"` @@ -84,22 +85,37 @@ func buildToolList() []CatalogTool { Source: "src/tool.go", } - // 从 JSON Schema 提取参数 + // 从 Schema (map[string]any) 提取参数 ct.Parameters = make(map[string]ParameterField) - for name, prop := range t.Parameters.Properties { - required := false - for _, r := range t.Parameters.Required { - if r == name { - required = true - break + if t.Parameters == nil { + list = append(list, ct) + continue + } + + props, _ := t.Parameters["properties"].(map[string]any) + required := make(map[string]bool) + if reqList, ok := t.Parameters["required"].([]any); ok { + for _, r := range reqList { + if s, ok := r.(string); ok { + required[s] = true } } + } + + for name, propRaw := range props { + prop, ok := propRaw.(map[string]any) + if !ok { + continue + } + typ, _ := prop["type"].(string) + desc, _ := prop["description"].(string) ct.Parameters[name] = ParameterField{ - Type: prop.Type, - Required: required, - Description: prop.Description, + Type: typ, + Required: required[name], + Description: desc, } } + list = append(list, ct) } return list @@ -181,8 +197,14 @@ func scanAgents() []CatalogAgent { continue } + agentType := "main" + if t, ok := fm["type"]; ok { + agentType = fmt.Sprintf("%v", t) + } + cat := CatalogAgent{ Name: fmt.Sprintf("%v", fm["name"]), + Type: agentType, Path: fmt.Sprintf("agents/%s", e.Name()), Status: "active", } @@ -244,6 +266,21 @@ func GenerateToolsYAML() { // 注入目录到 system prompt // ============================================================ +// BuildSubAgentPrompt 生成可用子 Agent 列表,动态注入到主 Agent 的 system prompt +func BuildSubAgentPrompt(subs []*AgentDef) string { + if len(subs) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\n\n## 可用子 Agent\n") + b.WriteString("\n以下子 Agent 可通过 task 工具调度:\n") + for _, s := range subs { + b.WriteString(fmt.Sprintf("- **%s**: %s\n", s.Name, s.Description)) + } + b.WriteString("\n用 `task(\"agent_name\", {args})` 调度。不自己回答领域问题。\n") + return b.String() +} + // BuildInjectPrompt 生成能力边界目录,追加到 system prompt 末尾 func BuildInjectPrompt(toolNames []string) string { var b strings.Builder diff --git a/config.go b/config.go index 39338cd..4f9626b 100644 --- a/config.go +++ b/config.go @@ -58,13 +58,9 @@ func migrateOldConfig() { os.RemoveAll(old) } -// LoadConfig 从 ~/.config/yunshu/config.yaml 读取配置 -// 如果新目录不存在,自动从旧 weather-cli 目录迁移 +// LoadConfig 从 ~/.config/yunshu/config.yml 读取配置 func LoadConfig() (*Config, error) { - // 尝试自动迁移旧配置 - migrateOldConfig() - - path := filepath.Join(ConfigDir(), "config.yaml") + path := filepath.Join(ConfigDir(), "config.yml") data, err := os.ReadFile(path) if err != nil { return nil, err @@ -77,14 +73,14 @@ func LoadConfig() (*Config, error) { return &cfg, nil } -// SaveConfig 将配置写入 ~/.config/yunshu/config.yaml +// SaveConfig 将配置写入 ~/.config/yunshu/config.yml func SaveConfig(cfg *Config) error { dir := ConfigDir() if err := os.MkdirAll(dir, 0755); err != nil { return err } - path := filepath.Join(dir, "config.yaml") + path := filepath.Join(dir, "config.yml") data, err := yaml.Marshal(cfg) if err != nil { return err diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 6b0a155..e968d20 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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, Parameters(JSON 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 中新增"流式输出原则"章节。 diff --git a/docs/architecture.md b/docs/architecture.md index 353abe4..e7cd8ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 AgentRegistry(ScanAgents, GetMain, GetSub…) +llm.go LLM API 封装(豆包/OpenAI,sync.Once 延迟加载) +tool.go 工具注册 + safeMemoryPath + ExecuteTool + 7 个工具 handler +toolschema.go 泛型+反射工具注册(NewTool[T], structToSchema) +runtime.go RunAgent + RunSubAgent(maxToolCalls=2)+ cache + session +logger.go charmbracelet/log v2 全局实例(→ stderr) +log.go 双写 wrapper(warnLog/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 Schema,handler 内参数为类型安全的结构体字段。 + +## 核心流程 -### 当前(单 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` — 讨论历史 diff --git a/docs/changelog.md b/docs/changelog.md index 2c36dd9..91ccb09 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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.yml(YAML 序列,位于 ~/.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` | 天气子 Agent(type:sub),Markdown 排版 + 生活建议 | + +### ✨ 计划外新增 + +#### 泛型 + 反射工具注册(`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`:添加实现状态标记、多步骤编排章节、泛型注册章节 + +--- ### 架构规划:会议室模式 diff --git a/docs/taolun.md b/docs/taolun.md index 75236f9..52fac26 100644 --- a/docs/taolun.md +++ b/docs/taolun.md @@ -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 可以连续调多个子 Agent(weather → 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` | 追加本次讨论 | diff --git a/docs/会议室架构计划书.md b/docs/会议室架构计划书.md index 111804d..32edf53 100644 --- a/docs/会议室架构计划书.md +++ b/docs/会议室架构计划书.md @@ -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-agent)type: main │ - │ 人格 + 调度规则 + task + memory 工具 │ - │ 唯一入口,用户只和它对话 │ - └──────┬───────────────────────────────────────┘ - │ task("weather", {city: "北京"}) - │ task("earthquake", {region: "通州"}) - │ task("memory", {action: "read", ...}) - ▼ - ┌───────────────────────────────────────────┐ - │ 发言人(领域子 Agent)type: sub │ - │ weather / earthquake / memory / narrator │ - │ 被调才说话,返回文本 + 可选缓存数据 │ - │ 各自的 cache / skills / tools 互相隔离 │ - └───────────────────────────────────────────┘ + ┌──────▼──────────────────────────────────────────┐ + │ 主持者(dialog-agent)type: 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点…建国饭店…" ← 最终回答 + ▼ + ┌───────────────────────────────────────────────┐ + │ 发言人(领域子 Agent)type: 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. 调 LLM(session + 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 LLM(RunSubAgent,隔离的循环) 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 HOST(runtime.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 多步骤编排(新增能力) + +``` +用户: "去北京出差,明天走,待三天" + + HOST(runtime.go): + 1. 加载 dialog-agent.md → system prompt + 2. 读 session → 恢复上下文 + 3. 调 LLM(session + 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` | ✅ | 天气子 Agent,Markdown 输出 + 生活建议 | +| 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 | 个性化回答生成(成熟期) | ❌ | --- diff --git a/go.mod b/go.mod index 359f2da..c8e2db9 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,29 @@ module hub.gaomia.site/titor/YunShu -go 1.25.0 +go 1.25.8 -require gopkg.in/yaml.v3 v3.0.1 +require ( + charm.land/log/v2 v2.0.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + charm.land/lipgloss/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum index a62c313..b3a8821 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,47 @@ +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/llm.go b/llm.go index d231628..bd48570 100644 --- a/llm.go +++ b/llm.go @@ -1,54 +1,60 @@ package main import ( + "bufio" "bytes" "encoding/json" "fmt" "io" "net/http" "os" + "strings" + "sync" "time" + + "hub.gaomia.site/titor/YunShu/pkg/mdprint" ) var ( + llmOnce sync.Once llmHost = "https://ark.cn-beijing.volces.com/api/v3/chat/completions" llmModel = "doubao-seed-2-0-pro-260215" llmKey = "" ) -func init() { - // 1. 从配置文件加载 - cfg, err := LoadConfig() - if err == nil { - if cfg.LLM.Host != "" { - llmHost = cfg.LLM.Host +func loadLLMConfig() { + llmOnce.Do(func() { + cfg, err := LoadConfig() + if err == nil { + if cfg.LLM.Host != "" { + llmHost = cfg.LLM.Host + } + if cfg.LLM.Model != "" { + llmModel = cfg.LLM.Model + } + if cfg.LLM.Key != "" { + llmKey = cfg.LLM.Key + } } - if cfg.LLM.Model != "" { - llmModel = cfg.LLM.Model - } - if cfg.LLM.Key != "" { - llmKey = cfg.LLM.Key - } - } - // 2. 环境变量覆盖配置文件(优先级最高) - if v := os.Getenv("LLM_ENDPOINT"); v != "" { - llmHost = v - } - if v := os.Getenv("LLM_MODEL"); v != "" { - llmModel = v - } - if v := os.Getenv("LLM_API_KEY"); v != "" { - llmKey = v - } - // 兼容旧环境变量名 - if v := os.Getenv("OPENAI_API_KEY"); v != "" && llmKey == "" { - llmKey = v - } + if v := os.Getenv("LLM_ENDPOINT"); v != "" { + llmHost = v + } + if v := os.Getenv("LLM_MODEL"); v != "" { + llmModel = v + } + if v := os.Getenv("LLM_API_KEY"); v != "" { + llmKey = v + } + if v := os.Getenv("OPENAI_API_KEY"); v != "" && llmKey == "" { + llmKey = v + } + }) } // GetLLMKey 获取 API Key,优先使用已加载的密钥 func GetLLMKey() (string, error) { + loadLLMConfig() if llmKey == "" { return "", fmt.Errorf("未配置 API Key。请运行 'weather-cia onboard' 初始化,或设置 LLM_API_KEY 环境变量") } @@ -57,11 +63,14 @@ func GetLLMKey() (string, error) { // CallLLM 调用大模型 API(兼容 OpenAI Chat Completion 格式) func CallLLM(messages []Message, toolDefs []ToolDef) (*OpenAIResponse, error) { + loadLLMConfig() apiKey, err := GetLLMKey() if err != nil { return nil, err } + start := time.Now() + reqBody := map[string]interface{}{ "model": llmModel, "messages": messages, @@ -126,5 +135,269 @@ func CallLLM(messages []Message, toolDefs []ToolDef) (*OpenAIResponse, error) { return nil, fmt.Errorf("LLM 返回空结果") } + if result.Usage.TotalTokens > 0 { + infoLog("LLM 调用完成", + "tokens", result.Usage.TotalTokens, + "duration", time.Since(start).Round(time.Millisecond*100).String(), + ) + } return &result, nil } + +// ============================================================ +// 流式输出 (SSE) +// ============================================================ + +type accumulatedToolCall struct { + ID string + Type string + Name string + Args string +} + +type sseChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []sseChoice `json:"choices"` + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +type sseChoice struct { + Index int `json:"index"` + Delta sseDelta `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +type sseDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []sseToolCallDelta `json:"tool_calls,omitempty"` +} + +type sseToolCallDelta struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function *sseToolCallFunctionDelta `json:"function,omitempty"` +} + +type sseToolCallFunctionDelta struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +// CallLLMStream 流式调用 LLM,按 \n\n 段落边界缓冲后通过 mdprint 渲染到 stdout +func CallLLMStream(messages []Message, toolDefs []ToolDef) (*OpenAIResponse, error) { + loadLLMConfig() + apiKey, err := GetLLMKey() + if err != nil { + return nil, err + } + + start := time.Now() + + reqBody := map[string]any{ + "model": llmModel, + "messages": messages, + "stream": true, + } + + if len(toolDefs) > 0 { + tools := make([]OpenAITool, 0, len(toolDefs)) + for _, td := range toolDefs { + tools = append(tools, OpenAITool{ + Type: "function", + Function: OpenAIToolFunc{ + Name: td.Name, + Description: td.Description, + Parameters: td.Parameters, + }, + }) + } + reqBody["tools"] = tools + reqBody["tool_choice"] = "auto" + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + req, err := http.NewRequest("POST", llmHost, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 LLM 失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + respData, _ := io.ReadAll(resp.Body) + var errResp OpenAIErrorResponse + if json.Unmarshal(respData, &errResp) == nil && errResp.Error.Message != "" { + return nil, fmt.Errorf("LLM API 错误 [%s]: %s", errResp.Error.Type, errResp.Error.Message) + } + return nil, fmt.Errorf("LLM API 返回 HTTP %d: %s", resp.StatusCode, string(respData)) + } + + reader := bufio.NewReader(resp.Body) + var fullContent strings.Builder + var blockBuf strings.Builder + toolCallAccums := make(map[int]*accumulatedToolCall) + var responseID, responseModel string + var responseCreated int64 + var usage *OpenAIUsage + + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } + return nil, fmt.Errorf("读取流响应失败: %w", err) + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk sseChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + if responseID == "" && chunk.ID != "" { + responseID = chunk.ID + } + if responseModel == "" && chunk.Model != "" { + responseModel = chunk.Model + } + if responseCreated == 0 && chunk.Created != 0 { + responseCreated = chunk.Created + } + if chunk.Usage != nil { + usage = chunk.Usage + } + + for _, choice := range chunk.Choices { + delta := choice.Delta + + if delta.Content != "" { + fullContent.WriteString(delta.Content) + blockBuf.WriteString(delta.Content) + tryFlushBlocks(&blockBuf) + } + + for _, tc := range delta.ToolCalls { + acc, ok := toolCallAccums[tc.Index] + if !ok { + acc = &accumulatedToolCall{} + toolCallAccums[tc.Index] = acc + } + if tc.ID != "" { + acc.ID = tc.ID + } + if tc.Type != "" { + acc.Type = tc.Type + } + if tc.Function != nil { + if tc.Function.Name != "" { + acc.Name = tc.Function.Name + } + acc.Args += tc.Function.Arguments + } + } + } + } + + // 流结束,刷残段 + if blockBuf.Len() > 0 { + mdprint.Print(blockBuf.String()) + } + + // 重建响应 + var choice OpenAIChoice + + if len(toolCallAccums) > 0 { + var tcs []ToolCall + for i := 0; i < len(toolCallAccums); i++ { + acc := toolCallAccums[i] + if acc == nil { + continue + } + tcs = append(tcs, ToolCall{ + ID: acc.ID, + Type: acc.Type, + Function: ToolCallFunction{ + Name: acc.Name, + Arguments: acc.Args, + }, + }) + } + choice.Message.ToolCalls = tcs + } else { + content := fullContent.String() + if content != "" { + choice.Message.Content = &content + } + } + + result := &OpenAIResponse{ + ID: responseID, + Object: "chat.completion", + Created: responseCreated, + Model: responseModel, + Choices: []OpenAIChoice{choice}, + } + + if usage != nil && usage.TotalTokens > 0 { + result.Usage = *usage + infoLog("LLM 调用完成", + "tokens", usage.TotalTokens, + "duration", time.Since(start).Round(time.Millisecond*100).String(), + ) + } + + return result, nil +} + +// tryFlushBlocks 检测 blockBuf 中是否有完整的 Markdown block(以 \n\n 为界) +// 有则通过 mdprint 渲染到 stdout,剩余残段留在 buf 中继续缓冲 +func tryFlushBlocks(buf *strings.Builder) { + content := buf.String() + idx := strings.LastIndex(content, "\n\n") + if idx < 0 { + return + } + + complete := strings.TrimRight(content[:idx], "\n\r\t ") + if complete == "" { + return + } + + mdprint.Print(complete) + + remainder := content[idx+2:] + buf.Reset() + if remainder != "" { + buf.WriteString(remainder) + } +} diff --git a/log.go b/log.go new file mode 100644 index 0000000..3786a1d --- /dev/null +++ b/log.go @@ -0,0 +1,249 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "hub.gaomia.site/titor/YunShu/pkg/style" + "gopkg.in/yaml.v3" +) + +type LogLevel string + +const ( + LevelWarn LogLevel = "warn" + LevelError LogLevel = "error" + LevelInfo LogLevel = "info" + LevelOK LogLevel = "ok" +) + +var logToStderr bool + +type logEntry struct { + Time string `yaml:"time"` + Level LogLevel `yaml:"level"` + Msg string `yaml:"msg"` + Fields map[string]any `yaml:",inline,omitempty"` +} + +func logPath() string { + return filepath.Join(ConfigDir(), "log.yml") +} + +func buildFields(keyvals []any) map[string]any { + m := make(map[string]any) + for i := 0; i < len(keyvals)-1; i += 2 { + if key, ok := keyvals[i].(string); ok { + m[key] = keyvals[i+1] + } + } + return m +} + +func appendLog(level LogLevel, msg string, keyvals ...any) { + path := logPath() + var entries []logEntry + if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + yaml.Unmarshal(data, &entries) + } + if entries == nil { + entries = make([]logEntry, 0) + } + + entry := logEntry{ + Time: time.Now().Format(time.RFC3339), + Level: level, + Msg: msg, + } + if len(keyvals) > 0 { + entry.Fields = buildFields(keyvals) + } + + entries = append(entries, entry) + + out, err := yaml.Marshal(entries) + if err != nil { + return + } + os.WriteFile(path, out, 0644) +} + +func readLogs() ([]logEntry, error) { + data, err := os.ReadFile(logPath()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil, nil + } + var entries []logEntry + if err := yaml.Unmarshal(data, &entries); err != nil { + return nil, err + } + return entries, nil +} + +func clearLogs() { + os.WriteFile(logPath(), []byte("[]\n"), 0644) +} + +func levelColor(l LogLevel) *style.Style { + switch l { + case LevelWarn: + return style.Yellow + case LevelError: + return style.Red + case LevelInfo: + return style.Cyan + case LevelOK: + return style.Green + default: + return style.Dim + } +} + +func displayLogs(entries []logEntry, filterLevel string, top int) { + if len(entries) == 0 { + fmt.Println(style.Dim.Render("(暂无日志)")) + return + } + + // 按时间倒序 + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + + // 筛选级别 + if filterLevel != "" { + var filtered []logEntry + for _, e := range entries { + if string(e.Level) == filterLevel { + filtered = append(filtered, e) + } + } + entries = filtered + } + + if top > 0 && top < len(entries) { + entries = entries[:top] + } + + for _, e := range entries { + ls := levelColor(e.Level) + label := ls.Render(fmt.Sprintf("[%-5s]", strings.ToUpper(string(e.Level)))) + timeStr := style.Dim.Render(e.Time) + fmt.Printf("%s %s %s\n", label, timeStr, e.Msg) + for k, v := range e.Fields { + fmt.Printf(" %s = %v\n", style.Dim.Render(k), style.Dim.Render(fmt.Sprintf("%v", v))) + } + } +} + +func followLogs(filterLevel string) { + lastCount := 0 + first := true + + for { + entries, err := readLogs() + if err != nil { + time.Sleep(2 * time.Second) + continue + } + + n := len(entries) + if n == 0 { + if first { + fmt.Println(style.Dim.Render("等待新日志...")) + first = false + } + time.Sleep(2 * time.Second) + continue + } + + if first { + fmt.Println(style.Dim.Render("日志监听中... Ctrl+C 退出")) + fmt.Println() + lastCount = n + first = false + time.Sleep(2 * time.Second) + continue + } + + if n > lastCount { + for _, e := range entries[lastCount:] { + if filterLevel != "" && string(e.Level) != filterLevel { + continue + } + ls := levelColor(e.Level) + label := ls.Render(fmt.Sprintf("[%-5s]", strings.ToUpper(string(e.Level)))) + timeStr := style.Dim.Render(e.Time) + fmt.Printf("%s %s %s\n", label, timeStr, e.Msg) + for k, v := range e.Fields { + fmt.Printf(" %s = %v\n", style.Dim.Render(k), style.Dim.Render(fmt.Sprintf("%v", v))) + } + } + } + lastCount = n + + time.Sleep(2 * time.Second) + } +} + +func runLogCmd(args []string) { + fs := flag.NewFlagSet("log", flag.ExitOnError) + top := fs.Int("top", 0, "显示最后 N 条日志") + level := fs.String("level", "", "按级别过滤 (warn/error/info/ok)") + clear := fs.Bool("clear", false, "清空日志文件") + watch := fs.Bool("watch", false, "监听模式,实时输出新日志") + fs.Parse(args) + + if *clear { + if *top > 0 || *level != "" || *watch { + fmt.Fprintln(os.Stderr, style.Red.Render("--clear 不能与其他选项组合")) + os.Exit(1) + } + clearLogs() + fmt.Println(style.Green.Render("日志已清空")) + return + } + + if *watch { + followLogs(*level) + return + } + + entries, err := readLogs() + if err != nil { + fmt.Fprintln(os.Stderr, style.Red.Render("读取日志失败: "+err.Error())) + os.Exit(1) + } + displayLogs(entries, *level, *top) +} + +func warnLog(msg string, keyvals ...any) { + if logToStderr { + Log.Warn(msg, keyvals...) + } + appendLog(LevelWarn, msg, keyvals...) +} + +func errorLog(msg string, keyvals ...any) { + if logToStderr { + Log.Error(msg, keyvals...) + } + appendLog(LevelError, msg, keyvals...) +} + +func infoLog(msg string, keyvals ...any) { + if logToStderr { + Log.Info(msg, keyvals...) + } + appendLog(LevelInfo, msg, keyvals...) +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..517dd7c --- /dev/null +++ b/logger.go @@ -0,0 +1,14 @@ +package main + +import ( + "os" + "time" + + "charm.land/log/v2" +) + +var Log = log.NewWithOptions(os.Stderr, log.Options{ + ReportTimestamp: true, + TimeFormat: time.TimeOnly, + Level: log.DebugLevel, +}) diff --git a/main.go b/main.go index 44ec7d2..775cd0c 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,19 @@ package main import ( + "encoding/json" "fmt" "os" + "path/filepath" "strings" "syscall" "hub.gaomia.site/titor/YunShu/pkg/style" "hub.gaomia.site/titor/YunShu/pkg/termui" + "gopkg.in/yaml.v3" ) -const version = "1.1.0" +const version = "2.3.0" func init() { kernel32 := syscall.NewLazyDLL("kernel32.dll") @@ -27,20 +30,18 @@ func printHelp() { fmt.Println() fmt.Println(style.Bold.Render("命令:")) fmt.Println(" onboard 交互式初始化配置") + fmt.Println(" log 查看日志 (--top, --level, --clear, --watch)") fmt.Println(" help, -h 显示帮助信息") fmt.Println(" version, -v 显示版本号") fmt.Println() fmt.Println(style.Bold.Render("示例:")) fmt.Println(" yunshu \"北京今天天气\" ", style.Dim.Render("单次天气查询")) fmt.Println(" yunshu ", style.Dim.Render("启动交互模式")) + fmt.Println(" yunshu log ", style.Dim.Render("查看日志")) + fmt.Println(" yunshu log --watch ", style.Dim.Render("实时监听日志")) fmt.Println(" yunshu onboard ", style.Dim.Render("重新初始化配置")) fmt.Println() - fmt.Println(style.Bold.Render("环境变量:")) - fmt.Println(" LLM_API_KEY API Key(优先级高于配置文件)") - fmt.Println(" LLM_ENDPOINT API 端点") - fmt.Println(" LLM_MODEL 模型名") - fmt.Println() - fmt.Println(style.Bold.Render("配置文件:"), "~/.config/yunshu/config.yaml") + fmt.Println(style.Bold.Render("配置文件:"), "~/.config/yunshu/config.yml") fmt.Println() } @@ -48,6 +49,118 @@ func printVersion() { fmt.Println("yunshu", version) } +func migrateMemoryJSON() { + memoryPath := filepath.Join(ConfigDir(), "memory.json") + data, err := os.ReadFile(memoryPath) + if err != nil { + return + } + + var store map[string]any + if err := json.Unmarshal(data, &store); err != nil { + return + } + + // personality → config/soul.md + if v, ok := store["personality"]; ok { + dir := filepath.Join(ConfigDir(), "config") + os.MkdirAll(dir, 0755) + os.WriteFile(filepath.Join(dir, "soul.md"), []byte("# AI 灵魂\n\n"+fmt.Sprint(v)+"\n"), 0644) + } + + // dialog_context → session/dialog.yml + if v, ok := store["dialog_context"]; ok { + dir := filepath.Join(ConfigDir(), "session") + os.MkdirAll(dir, 0755) + if out, err := yaml.Marshal(v); err == nil { + os.WriteFile(filepath.Join(dir, "dialog.yml"), out, 0644) + } + } + + // agent_errors → log.yml + if v, ok := store["agent_errors"]; ok { + if out, err := yaml.Marshal(v); err == nil { + os.WriteFile(filepath.Join(ConfigDir(), "log.yml"), out, 0644) + } + } + + os.Remove(memoryPath) + + // 确保 user.md 模板存在 + ensureUserConfig() +} + +func migrateFilePaths() { + dir := ConfigDir() + ensureSessionDir := func() string { + sd := filepath.Join(dir, "session") + os.MkdirAll(sd, 0755) + return sd + } + + readFile := func(p string) []byte { + d, _ := os.ReadFile(p) + return d + } + writeFile := func(p string, d []byte, perm os.FileMode) { + if len(d) > 0 { + os.WriteFile(p, d, perm) + } + } + + // config.yaml → config.yml + oldYaml := filepath.Join(dir, "config.yaml") + newYml := filepath.Join(dir, "config.yml") + if _, err := os.Stat(oldYaml); err == nil { + if _, err := os.Stat(newYml); os.IsNotExist(err) { + writeFile(newYml, readFile(oldYaml), 0600) + } + os.Remove(oldYaml) + } + + // session.json → session/session.json + oldSess := filepath.Join(dir, "session.json") + newSess := filepath.Join(ensureSessionDir(), "session.json") + if _, err := os.Stat(oldSess); err == nil { + if _, err := os.Stat(newSess); os.IsNotExist(err) { + writeFile(newSess, readFile(oldSess), 0644) + } + os.Remove(oldSess) + } + + // context/dialog.yaml → session/dialog.yml + oldDlg := filepath.Join(dir, "context", "dialog.yaml") + newDlg := filepath.Join(ensureSessionDir(), "dialog.yml") + if _, err := os.Stat(oldDlg); err == nil { + if _, err := os.Stat(newDlg); os.IsNotExist(err) { + writeFile(newDlg, readFile(oldDlg), 0644) + } + os.Remove(oldDlg) + os.Remove(filepath.Join(dir, "context")) + } + + // log.yaml → log.yml + oldLog := filepath.Join(dir, "log.yaml") + newLog := filepath.Join(dir, "log.yml") + if _, err := os.Stat(oldLog); err == nil { + if _, err := os.Stat(newLog); os.IsNotExist(err) { + writeFile(newLog, readFile(oldLog), 0644) + } + os.Remove(oldLog) + } +} + +func getMainAgent() *AgentDef { + r := ScanAgents() + def := r.GetMain("dialog") + if def == nil { + if m := r.ListMains(); len(m) > 0 { + def = m[0] + } + } + return def +} + func main() { args := os.Args[1:] @@ -56,6 +169,9 @@ func main() { case "onboard": runOnboard() return + case "log": + runLogCmd(args[1:]) + return case "help", "--help", "-h": printHelp() return @@ -65,14 +181,20 @@ func main() { default: if strings.HasPrefix(args[0], "-") { fmt.Fprintln(os.Stderr, style.Red.Render("未知选项: "+args[0])) - fmt.Fprintln(os.Stderr, "可用命令: onboard, help, version") + fmt.Fprintln(os.Stderr, "可用命令: onboard, log, help, version") os.Exit(1) } } } + // 迁移:旧目录、文件路径、旧格式 — 在 LoadConfig 前执行 + migrateOldConfig() + migrateFilePaths() + migrateMemoryJSON() + cfg, err := LoadConfig() if err != nil { + // 如果 config.yml 也不存在,才是真没配置 fmt.Fprintln(os.Stderr, style.Red.Render("未找到配置文件。请先运行:")) fmt.Fprintln(os.Stderr, " yunshu onboard") os.Exit(1) @@ -81,14 +203,18 @@ func main() { GenerateToolsYAML() - agentPath := SearchFile("agents/weather-agent.md") - def, err := LoadAgent(agentPath) - if err != nil { - fmt.Fprintln(os.Stderr, style.Red.Render("加载 agent 失败: "+err.Error())) + def := getMainAgent() + if def == nil { + fmt.Fprintln(os.Stderr, style.Red.Render("未找到主持者 Agent (type: main)")) + fmt.Fprintln(os.Stderr, "请检查 agents/ 目录下是否有 type: main 的 .md 文件") os.Exit(1) } + originalSystemPrompt := def.SystemPrompt if len(args) > 0 { + logToStderr = true + subs := ScanAgents().ListSubs() + def.SystemPrompt = originalSystemPrompt + BuildSubAgentPrompt(subs) ClearSession() query := strings.Join(args, " ") if err := RunAgent(def, query); err != nil { @@ -98,6 +224,7 @@ func main() { return } + logToStderr = false fmt.Println() fmt.Println(style.Cyan.Render("☁ 云枢·Agent"), style.Dim.Render("· 天气情报官")) fmt.Println(style.Dim.Render(" /exit 退出,// 开头的行不发给 LLM")) @@ -105,6 +232,15 @@ func main() { ClearSession() for { + // 热加载:每轮重新扫描 agent 文件 + r := ScanAgents() + if d := r.GetMain("dialog"); d != nil { + def = d + } else if mains := r.ListMains(); len(mains) > 0 { + def = mains[0] + } + def.SystemPrompt = originalSystemPrompt + BuildSubAgentPrompt(r.ListSubs()) + fmt.Print(style.Cyan.Render("❯ ")) input := termui.ReadLine() input = strings.TrimSpace(input) @@ -116,6 +252,24 @@ func main() { continue } + if strings.HasPrefix(input, "/log") { + arg := strings.TrimSpace(strings.TrimPrefix(input, "/log")) + switch arg { + case "on": + logToStderr = true + fmt.Println(style.Green.Render("日志显示已开启")) + case "off": + logToStderr = false + fmt.Println(style.Yellow.Render("日志显示已关闭")) + default: + fmt.Println("用法:") + fmt.Println(" /log on 开启日志显示") + fmt.Println(" /log off 关闭日志显示") + } + fmt.Println() + continue + } + switch input { case "/exit", "exit", "quit": fmt.Println("再见!") @@ -129,10 +283,11 @@ func main() { continue case "/help": fmt.Println("可用命令:") - fmt.Println(" /exit 退出") - fmt.Println(" /clear 清空会话") - fmt.Println(" /help 显示帮助") - fmt.Println(" // 不发给 LLM 的注释行") + fmt.Println(" /exit 退出") + fmt.Println(" /clear 清空会话") + fmt.Println(" /log on|off 控制日志显示") + fmt.Println(" /help 显示帮助") + fmt.Println(" // 不发给 LLM 的注释行") fmt.Println() continue } diff --git a/onboard.go b/onboard.go index 6b933d2..8f95701 100644 --- a/onboard.go +++ b/onboard.go @@ -9,6 +9,42 @@ import ( "hub.gaomia.site/titor/YunShu/pkg/termui" ) +const userMDTemplate = `# 用户画像 + +> 这里记录关于你的信息。通过对话自动更新,你也可以手动修改。 + +## 基本信息 + +- **称呼**: +- **常驻地**: +- **温度单位**:摄氏度 (C) + +## 偏好 + +- 过敏源: +- 兴趣: +- 出行习惯: + +## 备注 + +(自由添加,不会被自动覆盖) +` + +const soulMDTemplate = `# AI 灵魂 + +> 这里是 AI 的灵魂设定。你可以修改下面的话,改变我的性格和说话方式。 + +## 人设 + +你是一个友好、亲切的个人助理。说话简洁直接,偶尔可以幽默。 + +## 注意事项 + +- 尊重用户的隐私 +- 不主动推荐第三方服务 +- 对不确定的事情要坦诚,不编造 +` + func runOnboard() { fmt.Println() fmt.Println(style.Cyan.Render("☁ 云枢·Agent · 初始化配置")) @@ -57,6 +93,7 @@ func runOnboard() { CopyDefaultDir("agents", "agents") CopyDefaultDir("skills", "skills") CopyDefaultDir("data", "data") + ensureUserConfig() fmt.Println() @@ -67,7 +104,7 @@ func runOnboard() { fmt.Println() fmt.Println(style.Green.Render("✔ 配置完成!")) - fmt.Println(" 配置文件:", style.Dim.Render(filepath.Join(ConfigDir(), "config.yaml"))) + fmt.Println(" 配置文件:", style.Dim.Render(filepath.Join(ConfigDir(), "config.yml"))) fmt.Println() fmt.Println(" 运行示例:") fmt.Println(" " + style.Cyan.Render("yunshu \"北京今天天气\"")) @@ -99,3 +136,19 @@ func testLLM() { fmt.Println(style.Green.Render("\r✔ 连接成功!")) } + +func ensureUserConfig() { + dir := ConfigDir() + os.MkdirAll(filepath.Join(dir, "notes"), 0755) + os.MkdirAll(filepath.Join(dir, "config"), 0755) + + userPath := filepath.Join(dir, "config", "user.md") + if _, err := os.Stat(userPath); os.IsNotExist(err) { + os.WriteFile(userPath, []byte(userMDTemplate), 0644) + } + + soulPath := filepath.Join(dir, "config", "soul.md") + if _, err := os.Stat(soulPath); os.IsNotExist(err) { + os.WriteFile(soulPath, []byte(soulMDTemplate), 0644) + } +} diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..a4a2492 --- /dev/null +++ b/registry.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type AgentRegistry struct { + mains map[string]*AgentDef + subs map[string]*AgentDef +} + +func ScanAgents() *AgentRegistry { + r := &AgentRegistry{ + mains: make(map[string]*AgentDef), + subs: make(map[string]*AgentDef), + } + + dirs := []string{ + "agents", + filepath.Join(ConfigDir(), "agents"), + } + + seen := make(map[string]bool) + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || seen[e.Name()] { + continue + } + seen[e.Name()] = true + + agentPath := filepath.Join(dir, e.Name()) + def, err := LoadAgent(agentPath) + if err != nil { + warnLog("跳过 agent", "file", e.Name(), "err", err) + continue + } + + switch def.Type { + case "main": + r.mains[def.Name] = def + case "sub": + r.subs[def.Name] = def + } + } + } + + return r +} + +func (r *AgentRegistry) GetMain(name string) *AgentDef { + return r.mains[name] +} + +func (r *AgentRegistry) GetSub(name string) *AgentDef { + return r.subs[name] +} + +func (r *AgentRegistry) ListMains() []*AgentDef { + list := make([]*AgentDef, 0, len(r.mains)) + for _, def := range r.mains { + list = append(list, def) + } + return list +} + +func (r *AgentRegistry) ListSubs() []*AgentDef { + list := make([]*AgentDef, 0, len(r.subs)) + for _, def := range r.subs { + list = append(list, def) + } + return list +} + +func (r *AgentRegistry) String() string { + var b strings.Builder + b.WriteString(fmt.Sprintf("mains: %d, subs: %d\n", len(r.mains), len(r.subs))) + for _, def := range r.mains { + b.WriteString(fmt.Sprintf(" [main] %s: %s\n", def.Name, def.Description)) + } + for _, def := range r.subs { + b.WriteString(fmt.Sprintf(" [sub] %s: %s\n", def.Name, def.Description)) + } + return b.String() +} diff --git a/runtime.go b/runtime.go index 2bab810..9346dc8 100644 --- a/runtime.go +++ b/runtime.go @@ -1,22 +1,26 @@ package main import ( + "crypto/sha256" "encoding/json" "fmt" "os" "path/filepath" - - "hub.gaomia.site/titor/YunShu/pkg/mdprint" + "strings" + "time" ) func sessionPath() string { - return filepath.Join(ConfigDir(), "session.json") + return filepath.Join(ConfigDir(), "session", "session.json") } func ClearSession() { os.Remove(sessionPath()) + infoLog("会话已清空") } +const maxSessionMessages = 40 + func LoadSession() []Message { data, err := os.ReadFile(sessionPath()) if err != nil { @@ -25,9 +29,14 @@ func LoadSession() []Message { var messages []Message if err := json.Unmarshal(data, &messages); err != nil { + warnLog("解析 session.json 失败", "err", err) return nil } + if len(messages) > maxSessionMessages { + messages = messages[len(messages)-maxSessionMessages:] + } return messages + } func AppendToSession(msg Message) { @@ -37,11 +46,179 @@ func AppendToSession(msg Message) { data, err := json.MarshalIndent(messages, "", " ") if err != nil { + warnLog("序列化 session 失败", "err", err) return } os.WriteFile(sessionPath(), data, 0644) } +// ============================================================ +// Cache 辅助 +// ============================================================ + +func cacheDir() string { + return filepath.Join(ConfigDir(), "cache") +} + +func cacheFilePath(agentName string) string { + return filepath.Join(cacheDir(), agentName+".json") +} + +type cacheEntry struct { + CreatedAt time.Time `json:"created_at"` + TTL int `json:"ttl"` + Data interface{} `json:"data"` + Raw map[string]interface{} `json:"raw"` +} + +func buildCacheKey(keys []string, args map[string]interface{}) string { + parts := make([]string, 0) + for _, k := range keys { + if v, ok := args[k]; ok { + parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + } + } + if len(parts) == 0 { + return "" + } + h := sha256.Sum256([]byte(strings.Join(parts, "&"))) + return fmt.Sprintf("%x", h[:6]) +} + +func readCache(agentName, key string) *cacheEntry { + if key == "" { + return nil + } + data, err := os.ReadFile(cacheFilePath(agentName)) + if err != nil { + return nil + } + var store map[string]cacheEntry + if err := json.Unmarshal(data, &store); err != nil { + warnLog("解析缓存失败", "agent", agentName, "err", err) + return nil + } + entry, ok := store[key] + if !ok { + return nil + } + if time.Since(entry.CreatedAt) > time.Duration(entry.TTL)*time.Second { + delete(store, key) + return nil + } + return &entry +} + +func writeCache(agentName, key string, data interface{}, raw map[string]interface{}, ttl int) { + if key == "" { + return + } + store := make(map[string]cacheEntry) + existing, err := os.ReadFile(cacheFilePath(agentName)) + if err == nil { + if err := json.Unmarshal(existing, &store); err != nil { + warnLog("读取旧缓存解析失败", "agent", agentName, "err", err) + } + } + store[key] = cacheEntry{ + CreatedAt: time.Now(), + TTL: ttl, + Data: data, + Raw: raw, + } + dir := cacheDir() + os.MkdirAll(dir, 0755) + out, err := json.MarshalIndent(store, "", " ") + if err != nil { + warnLog("序列化缓存失败", "agent", agentName, "err", err) + return + } + os.WriteFile(cacheFilePath(agentName), out, 0644) +} + +// ============================================================ +// 子 Agent 返回解析 +// ============================================================ + +func parseSubResult(raw string) (text string, resultData interface{}) { + const resultMarker = "---RESULT---\n" + const textMarker = "\n---TEXT---" + + if !strings.Contains(raw, resultMarker) { + return raw, nil + } + + parts := strings.SplitN(raw, resultMarker, 2) + remaining := parts[1] + + resultEnd := strings.Index(remaining, textMarker) + if resultEnd == -1 { + return raw, nil + } + + jsonStr := strings.TrimSpace(remaining[:resultEnd]) + json.Unmarshal([]byte(jsonStr), &resultData) + text = strings.TrimSpace(remaining[resultEnd+len(textMarker):]) + return +} + +// ============================================================ +// RunSubAgent — 隔离的子 Agent 执行(不读写 session) +// ============================================================ + +func RunSubAgent(def *AgentDef, userInput string) (string, error) { + infoLog("子 Agent 开始", "agent", def.Name) + messages := []Message{ + {Role: RoleSystem, Content: def.SystemPrompt}, + {Role: RoleUser, Content: userInput}, + } + + toolDefs := GetToolDefs(def.Tools) + maxToolCalls := 2 + toolCallCount := 0 + + for { + resp, err := CallLLM(messages, toolDefs) + if err != nil { + return "", err + } + + choice := resp.Choices[0] + + if len(choice.Message.ToolCalls) > 0 { + toolCallCount++ + if toolCallCount > maxToolCalls { + warnLog("子 Agent 执行轮次超限", "agent", def.Name, "rounds", toolCallCount) + return "---TEXT---\n(子 Agent 执行轮次超限,已终止)", nil + } + assistantMsg := Message{ + Role: RoleAssistant, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + result, err := ExecuteTool(tc) + if err != nil { + result = fmt.Sprintf("工具执行错误: %v", err) + } + toolMsg := Message{ + Role: RoleTool, + Content: result, + ToolCallID: tc.ID, + } + messages = append(messages, toolMsg) + } + } else { + content := "" + if choice.Message.Content != nil { + content = *choice.Message.Content + } + return content, nil + } + } +} + func RunAgent(def *AgentDef, userInput string) error { messages := LoadSession() @@ -56,7 +233,8 @@ func RunAgent(def *AgentDef, userInput string) error { toolDefs := GetToolDefs(def.Tools) for { - resp, err := CallLLM(fullMessages, toolDefs) + fmt.Println() + resp, err := CallLLMStream(fullMessages, toolDefs) if err != nil { return err } @@ -89,6 +267,7 @@ func RunAgent(def *AgentDef, userInput string) error { if choice.Message.Content != nil { content = *choice.Message.Content } + assistantMsg := Message{ Role: RoleAssistant, Content: content, @@ -96,8 +275,6 @@ func RunAgent(def *AgentDef, userInput string) error { fullMessages = append(fullMessages, assistantMsg) AppendToSession(assistantMsg) - fmt.Println() - mdprint.Print(content) return nil } } diff --git a/tool.go b/tool.go index 25691ca..12470d5 100644 --- a/tool.go +++ b/tool.go @@ -6,8 +6,11 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "time" + + "gopkg.in/yaml.v3" ) var registeredTools = make(map[string]*ToolDef) @@ -34,13 +37,33 @@ func GetToolDefs(names []string) []ToolDef { return defs } +func safeMemoryPath(path string) (string, error) { + cleanPath := filepath.Clean(path) + fullPath := filepath.Join(ConfigDir(), cleanPath) + + realPath, err := filepath.EvalSymlinks(fullPath) + if err != nil { + if !os.IsNotExist(err) { + return "", fmt.Errorf("路径解析失败: %w", err) + } + realPath = fullPath + } + + rel, err := filepath.Rel(ConfigDir(), realPath) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("路径越界: %s", path) + } + + return fullPath, nil +} + func ExecuteTool(tc ToolCall) (string, error) { td, ok := registeredTools[tc.Function.Name] if !ok { return "", fmt.Errorf("未知工具: %s", tc.Function.Name) } - var args map[string]interface{} + var args map[string]any if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return "", fmt.Errorf("解析工具参数失败: %w", err) } @@ -48,36 +71,142 @@ func ExecuteTool(tc ToolCall) (string, error) { return td.Execute(args) } -func init() { - RegisterTool(&ToolDef{ - Name: "http-get", - Description: "发送 HTTP GET 请求获取数据", - Parameters: ToolParameter{ - Type: "object", - Properties: map[string]ToolProperty{ - "url": {Type: "string", Description: "请求的完整 URL 地址"}, - "headers": {Type: "string", Description: "请求头,JSON 格式的键值对字符串,如 {\"User-Agent\": \"...\"}"}, - }, - Required: []string{"url"}, - }, - Execute: func(args map[string]interface{}) (string, error) { - url, _ := args["url"].(string) - if url == "" { - return "", fmt.Errorf("缺少 url 参数") - } +// ============================================================ +// 工具输入结构体 +// ============================================================ - req, err := http.NewRequest("GET", url, nil) +type HTTPGetInput struct { + URL string `json:"url" description:"请求的完整 URL 地址"` + Headers map[string]string `json:"headers,omitempty" description:"请求头键值对,如 {\"User-Agent\": \"...\"}"` +} + +type SkillInput struct { + Name string `json:"name" description:"Skill 名称,如 msn-weather-api"` +} + +type ReadFileInput struct { + Path string `json:"path" description:"文件路径,相对于项目根目录"` +} + +type TaskInput struct { + Agent string `json:"agent" description:"子 Agent 名称,如 weather"` + Args map[string]any `json:"args" description:"子 Agent 参数对象"` +} + +type MemoryReadInput struct { + Path string `json:"path" description:"文件路径。config/user.md(画像)、config/soul.md(AI灵魂)、session/dialog.yml(对话摘要)、notes/*.md(备忘录) 等。留空返回可用文件列表"` +} + +type MemoryWriteInput struct { + Path string `json:"path" description:"文件路径。.md 按 ## 标题合并(value 传字符串);.yml 按 key 合并(value 传对象)"` + Value interface{} `json:"value" description:"内容。格式取决于文件类型"` +} + +type GeocodeInput struct { + City string `json:"city" description:"城市名称,支持中文(如 北京)或英文(如 Beijing)"` +} + +// ============================================================ +// 工具注册 +// ============================================================ + +// mdMerge 按 ## 标题合并 Markdown 文件。 +// incoming 中的标题覆盖 existing 中同名的,其他段保留。 +func mdMerge(existing, incoming string) string { + if existing == "" { + return incoming + } + if incoming == "" { + return existing + } + + type section struct { + heading string + content string + } + + parse := func(text string) []section { + var secs []section + var cur section + for _, line := range strings.Split(text, "\n") { + if h, ok := strings.CutPrefix(line, "## "); ok && h != "" { + if cur.heading != "" || cur.content != "" { + secs = append(secs, cur) + } + cur = section{heading: h} + } else { + if cur.content != "" { + cur.content += "\n" + } + cur.content += line + } + } + if cur.heading != "" || cur.content != "" { + secs = append(secs, cur) + } + return secs + } + + existingSecs := parse(existing) + incomingSecs := parse(incoming) + + headingIdx := make(map[string]int) + for i, s := range existingSecs { + headingIdx[s.heading] = i + } + + seen := make(map[string]bool) + var merged []section + + for _, s := range existingSecs { + found := false + for _, in := range incomingSecs { + if in.heading == s.heading { + merged = append(merged, in) + seen[in.heading] = true + found = true + break + } + } + if !found { + merged = append(merged, s) + } + } + + for _, in := range incomingSecs { + if !seen[in.heading] { + merged = append(merged, in) + } + } + + var out strings.Builder + for i, s := range merged { + if i > 0 { + out.WriteString("\n") + } + if s.heading != "" { + out.WriteString("## ") + out.WriteString(s.heading) + out.WriteString("\n") + } + if strings.TrimSpace(s.content) != "" { + out.WriteString(strings.TrimRight(s.content, "\n")) + out.WriteString("\n") + } + } + return out.String() +} + +func init() { + RegisterTool(NewTool[HTTPGetInput]("http-get", + "发送 HTTP GET 请求获取数据", + func(args HTTPGetInput) (string, error) { + req, err := http.NewRequest("GET", args.URL, nil) if err != nil { return "", fmt.Errorf("创建请求失败: %w", err) } - - if headersStr, ok := args["headers"].(string); ok && headersStr != "" { - var headers map[string]string - if err := json.Unmarshal([]byte(headersStr), &headers); err == nil { - for k, v := range headers { - req.Header.Set(k, v) - } - } + for k, v := range args.Headers { + req.Header.Set(k, v) } client := &http.Client{Timeout: 15 * time.Second} @@ -95,70 +224,162 @@ func init() { if resp.StatusCode != 200 { return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), nil } - return string(body), nil }, - }) + )) - RegisterTool(&ToolDef{ - Name: "skill", - Description: "加载指定名称的 Skill 知识内容到当前上下文,获取专业知识", - Parameters: ToolParameter{ - Type: "object", - Properties: map[string]ToolProperty{ - "name": {Type: "string", Description: "Skill 名称,如 msn-weather-api"}, - }, - Required: []string{"name"}, + RegisterTool(NewTool[SkillInput]("skill", + "加载指定名称的 Skill 知识内容到当前上下文,获取专业知识", + func(args SkillInput) (string, error) { + return LoadSkill(args.Name) }, - Execute: func(args map[string]interface{}) (string, error) { - name, _ := args["name"].(string) - if name == "" { - return "", fmt.Errorf("缺少 name 参数") - } - return LoadSkill(name) - }, - }) + )) - RegisterTool(&ToolDef{ - Name: "read-file", - Description: "读取本地文件内容", - Parameters: ToolParameter{ - Type: "object", - Properties: map[string]ToolProperty{ - "path": {Type: "string", Description: "文件路径,相对于项目根目录"}, - }, - Required: []string{"path"}, - }, - Execute: func(args map[string]interface{}) (string, error) { - path, _ := args["path"].(string) - if path == "" { - return "", fmt.Errorf("缺少 path 参数") - } - data, err := os.ReadFile(path) + RegisterTool(NewTool[ReadFileInput]("read-file", + "读取本地文件内容", + func(args ReadFileInput) (string, error) { + data, err := os.ReadFile(args.Path) if err != nil { return "", fmt.Errorf("读取文件失败: %w", err) } - return strings.TrimSpace(string(data)), nil + return string(data), nil }, - }) + )) - RegisterTool(&ToolDef{ - Name: "geocode", - Description: "查询城市或地点的经纬度坐标,返回 lat/lon/name/country。支持中文城市名(如 北京、上海、成都)和英文名", - Parameters: ToolParameter{ - Type: "object", - Properties: map[string]ToolProperty{ - "city": {Type: "string", Description: "城市名称,支持中文(如 北京)或英文(如 Beijing)"}, - }, - Required: []string{"city"}, - }, - Execute: func(args map[string]interface{}) (string, error) { - city, _ := args["city"].(string) - if city == "" { - return "", fmt.Errorf("缺少 city 参数") + RegisterTool(NewTool[TaskInput]("task", + "调度子 Agent 执行领域任务。sub-agent 加载后自动查缓存,有缓存直接返回,无缓存调 LLM + 工具链获取新数据", + func(args TaskInput) (string, error) { + infoLog("task("+args.Agent+") 开始") + registry := ScanAgents() + sub := registry.GetSub(args.Agent) + if sub == nil { + errorLog("未找到子 Agent", "agent", args.Agent) + return "", fmt.Errorf("未找到子 Agent: %s", args.Agent) } - url := fmt.Sprintf("https://wttr.in/%s?format=j1", city) + var cacheKey string + var cacheData interface{} + if sub.Cache != nil && len(sub.Cache.Keys) > 0 { + cacheKey = buildCacheKey(sub.Cache.Keys, args.Args) + if entry := readCache(args.Agent, cacheKey); entry != nil { + cacheData = entry.Data + } + } + + subInput := map[string]any{ + "args": args.Args, + "cache_data": cacheData, + } + subInputBytes, _ := json.Marshal(subInput) + + result, err := RunSubAgent(sub, string(subInputBytes)) + if err != nil { + errorLog("task("+args.Agent+") 失败", "err", err) + return "", fmt.Errorf("子 Agent %s 执行失败: %w", args.Agent, err) + } + + text, resultData := parseSubResult(result) + + if cacheKey != "" && resultData != nil && sub.Cache != nil { + writeCache(args.Agent, cacheKey, resultData, args.Args, sub.Cache.TTL) + } + + infoLog("task("+args.Agent+") 完成") + return text, nil + }, + )) + + RegisterTool(NewTool[MemoryReadInput]("memory.read", + "读取记忆文件。路径支持 config/, session/, log.yml, notes/ 等。返回文件原始内容", + func(args MemoryReadInput) (string, error) { + fullPath, err := safeMemoryPath(args.Path) + if err != nil { + return "", err + } + info, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return "null", nil + } + return "", fmt.Errorf("读取失败: %w", err) + } + if info.IsDir() { + entries, err := os.ReadDir(fullPath) + if err != nil { + return "", fmt.Errorf("读取目录失败: %w", err) + } + names := make([]string, 0) + for _, e := range entries { + if !e.IsDir() { + names = append(names, e.Name()) + } + } + out, _ := yaml.Marshal(names) + return string(out), nil + } + + data, err := os.ReadFile(fullPath) + if err != nil { + return "", fmt.Errorf("读取失败: %w", err) + } + return string(data), nil + }, + )) + + RegisterTool(NewTool[MemoryWriteInput]("memory.write", + "写入记忆文件。.md 按 ## 标题合并(value 传字符串),.yml 按 key 合并(value 传对象)。目录自动创建", + func(args MemoryWriteInput) (string, error) { + fullPath, err := safeMemoryPath(args.Path) + if err != nil { + return "", err + } + os.MkdirAll(filepath.Dir(fullPath), 0755) + + ext := filepath.Ext(args.Path) + switch ext { + case ".yaml", ".yml": + existing := make(map[string]any) + if data, err := os.ReadFile(fullPath); err == nil { + if err := yaml.Unmarshal(data, &existing); err != nil { + warnLog("解析 yml 失败", "path", args.Path, "err", err) + } + } + if m, ok := args.Value.(map[string]any); ok { + for k, v := range m { + existing[k] = v + } + } + out, err := yaml.Marshal(existing) + if err != nil { + return "", fmt.Errorf("序列化 yml 失败: %w", err) + } + if err := os.WriteFile(fullPath, out, 0644); err != nil { + return "", fmt.Errorf("写入失败: %w", err) + } + default: + str, ok := args.Value.(string) + if !ok { + return "", fmt.Errorf(".md 文件 value 必须是字符串") + } + existing := "" + if data, err := os.ReadFile(fullPath); err == nil { + existing = string(data) + } else if !os.IsNotExist(err) { + warnLog("读取 md 失败", "path", args.Path, "err", err) + } + merged := mdMerge(existing, str) + if err := os.WriteFile(fullPath, []byte(merged), 0644); err != nil { + return "", fmt.Errorf("写入失败: %w", err) + } + } + return "ok", nil + }, + )) + + RegisterTool(NewTool[GeocodeInput]("geocode", + "查询城市或地点的经纬度坐标,返回 lat/lon/name/country。支持中文城市名(如 北京、上海、成都)和英文名", + func(args GeocodeInput) (string, error) { + url := fmt.Sprintf("https://wttr.in/%s?format=j1", args.City) req, err := http.NewRequest("GET", url, nil) if err != nil { return "", fmt.Errorf("创建请求失败: %w", err) @@ -182,15 +403,10 @@ func init() { var result struct { NearestArea []struct { - AreaName []struct { - Value string `json:"value"` - } `json:"areaName"` - Country []struct { - Value string `json:"value"` - } `json:"country"` - Latitude string `json:"latitude"` - Longitude string `json:"longitude"` - Population string `json:"population"` + AreaName []struct{ Value string `json:"value"` } `json:"areaName"` + Country []struct{ Value string `json:"value"` } `json:"country"` + Latitude string `json:"latitude"` + Longitude string `json:"longitude"` } `json:"nearest_area"` } @@ -199,7 +415,7 @@ func init() { } if len(result.NearestArea) == 0 { - return "", fmt.Errorf("未找到城市: %s", city) + return "", fmt.Errorf("未找到城市: %s", args.City) } area := result.NearestArea[0] @@ -212,7 +428,7 @@ func init() { country = area.Country[0].Value } - out, _ := json.Marshal(map[string]interface{}{ + out, _ := json.Marshal(map[string]any{ "lat": area.Latitude, "lon": area.Longitude, "name": name, @@ -220,5 +436,5 @@ func init() { }) return string(out), nil }, - }) + )) } diff --git a/toolschema.go b/toolschema.go new file mode 100644 index 0000000..bde7489 --- /dev/null +++ b/toolschema.go @@ -0,0 +1,133 @@ +package main + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +func structToSchema(t reflect.Type) Schema { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return typeToSchema(t) + } + + schema := Schema{ + "type": "object", + "properties": map[string]any{}, + } + + properties := schema["properties"].(map[string]any) + var required []any + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + + if !f.IsExported() { + continue + } + + jsonTag := f.Tag.Get("json") + if jsonTag == "-" { + continue + } + + name := strings.Split(jsonTag, ",")[0] + if name == "" { + name = strings.ToLower(f.Name[:1]) + f.Name[1:] + } + + if !strings.Contains(jsonTag, "omitempty") { + required = append(required, name) + } + + fieldSchema := typeToSchema(f.Type) + + if desc := f.Tag.Get("description"); desc != "" { + fieldSchema["description"] = desc + } + + if enum := f.Tag.Get("enum"); enum != "" { + vals := strings.Split(enum, ",") + enumVals := make([]any, len(vals)) + for i, v := range vals { + enumVals[i] = strings.TrimSpace(v) + } + fieldSchema["enum"] = enumVals + } + + properties[name] = fieldSchema + } + + if len(required) > 0 { + schema["required"] = required + } + + return schema +} + +func typeToSchema(t reflect.Type) Schema { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch t.Kind() { + case reflect.String: + return Schema{"type": "string"} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return Schema{"type": "integer"} + case reflect.Float32, reflect.Float64: + return Schema{"type": "number"} + case reflect.Bool: + return Schema{"type": "boolean"} + case reflect.Slice, reflect.Array: + items := typeToSchema(t.Elem()) + return Schema{"type": "array", "items": items} + case reflect.Interface: + return Schema{} + case reflect.Map: + m := Schema{"type": "object"} + if t.Elem().Kind() != reflect.Interface { + m["additionalProperties"] = typeToSchema(t.Elem()) + } + return m + case reflect.Struct: + return structToSchema(t) + default: + return Schema{"type": "string"} + } +} + +func NewTool[T any](name, description string, fn func(T) (string, error)) *ToolDef { + var zero T + t := reflect.TypeOf(zero) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + panic(fmt.Sprintf("NewTool: %T 不是结构体类型", zero)) + } + + schema := structToSchema(t) + + return &ToolDef{ + Name: name, + Description: description, + Parameters: schema, + Execute: func(args map[string]any) (string, error) { + data, err := json.Marshal(args) + if err != nil { + return "", fmt.Errorf("序列化参数失败: %w", err) + } + var typed T + if err := json.Unmarshal(data, &typed); err != nil { + return "", fmt.Errorf("参数解析失败: %w", err) + } + return fn(typed) + }, + } +} diff --git a/types.go b/types.go index 23f805f..e86ec57 100644 --- a/types.go +++ b/types.go @@ -27,28 +27,26 @@ type ToolCallFunction struct { Arguments string `json:"arguments"` } +type CacheDef struct { + TTL int `yaml:"ttl"` + Keys []string `yaml:"keys"` +} + type AgentDef struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Tools []string `yaml:"tools"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Type string `yaml:"type"` + Cache *CacheDef `yaml:"cache,omitempty"` + Tools []string `yaml:"tools"` SystemPrompt string } -type ToolParameter struct { - Type string `json:"type"` - Properties map[string]ToolProperty `json:"properties"` - Required []string `json:"required"` -} - -type ToolProperty struct { - Type string `json:"type"` - Description string `json:"description"` -} +type Schema map[string]any type ToolDef struct { Name string Description string - Parameters ToolParameter + Parameters Schema Execute func(args map[string]interface{}) (string, error) }