2 Commits
v1.1.0 ... main

Author SHA1 Message Date
titor
c4a0e3ef53 feat: v2.3.0 流式输出 + 日志系统 + 会议室架构全面升级
- 流式输出: SSE 逐 token 接收, \\n\n\ 段落缓冲后 mdprint 彩色渲染
- 日志系统: charmbracelet/log v2 双写(stderr + log.yml), yunshu log 命令
- 会议室架构: dialog(main) + weather/profile/note(sub) 多 Agent 编排
- 泛型工具注册: NewTool[T] 反射推导 JSON Schema, 类型安全
- 安全加固: safeMemoryPath 三段校验(EvalSymlinks+Rel), maxToolCalls=2
- 性能优化: sync.Once 延迟加载, note 一步完成, obs/summary 合并
- Prompt 适配: 流式输出原则(先调工具不说话), 单 Agent 查询跳过 obs+summary
- 文档: AGENTS.md + architecture.md + changelog.md 全部同步至 v2.3.0
2026-05-16 17:21:29 +08:00
titor
0898188086 docs: 会议室架构规划 + MSN hourlyforecast 端点更新
- 新增 docs/会议室架构计划书.md 完整架构方案(主持者+子Agent+task+cache+记忆)
- 更新 taolun.md 追加 2026-05-11 讨论历史
- 更新 AGENTS.md 规范(type, cache 字段)
- 更新 architecture.md 后续演进章节
- 更新 changelog.md 架构规划里程碑
- 修复 MSN 天气接口文档:新增 hourlyforecast,标记 weathertrends 已失效
- 更新 skills/msn-weather-api/SKILL.md 新增 hourlyforecast 端点
- 更新 agents/weather-agent.md 支持逐小时查询
2026-05-11 08:32:30 +08:00
26 changed files with 3425 additions and 246 deletions

128
agents/dialog-agent.md Normal file
View File

@@ -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 是内部日志,**不要展示给用户**

128
agents/note-sub.md Normal file
View File

@@ -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 列表格式一致
- 不要在列表中嵌套复杂结构(复杂内容请用独立文件)
- **读/写后直接返回,不要多余步骤**

62
agents/profile-sub.md Normal file
View File

@@ -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
## 画像
- **称呼**: 小张
- **常驻地**: 北京通州
- **职业**: 后端开发
- **偏好**: 喜欢直接答案
```
### 重要原则
- **不覆盖**:用户已有的信息不要改,除非用户说"不对,我要改"
- **不编造**:用户没说过的信息不要编造填充
- **不猜测**:不确定的不要写(比如"看起来可能住在北京"这种不要写)
- **保持格式**:只写 `## 画像` 段的内容,其他段不动

View File

@@ -1,34 +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` 请求天气数据
5. **分析回答** — 解析 JSON 并给出清晰、有用的回答
## 追问处理
- 如果用户追问(如"适合穿什么?""风大不大?"),优先基于已有数据回答,无需重复 API 调用
- 如果用户问另一个城市,重新执行完整流程
- 如果数据明显过时(超过 2 小时),重新请求
## 输出规范
回答要清晰友好,包含关键信息:
- 当前温度、体感温度、天气状况
- 湿度、风速、空气质量
- 根据天气给出实用建议(如"建议带伞""适合户外"等)

164
agents/weather-sub.md Normal file
View File

@@ -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`
- 每个章节之间空一行

View File

@@ -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

View File

@@ -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

View File

@@ -24,39 +24,81 @@
## Agent 定义规范(.md 文件)
- 必须包含 YAML frontmatter`---` 包裹)
- frontmatter 必需字段:`name`, `description`, `tools`
- frontmatter 必需字段:`name`, `description`, `type`, `tools`
- `type` 可选值:`main`(主持者/对话 Agent唯一入口`sub`(领域专家,被 task 调)
- `cache` 可选字段:`ttl`(过期秒数)、`keys`(从 args 中提取的缓存 key 字段列表)
- tools 为数组,声明 agent 需要的工具名(在 tool.go 中注册)
- body 为 system prompt**只定义行为逻辑**(角色、工作流程、输出规范)
- **关键技术细节URL、apiKey、请求头、JSON 路径等)不要 inline 在 agent skill 中**,改为:
- 放到 `skills/*/SKILL.md` 中,由 agent 调用 `skill("name")` 按需加载
- 或注册为 tool确定性操作由 agent 声明 tools 即可调用
- session 文件存在 `~/.config/weather-cli/session.json`,不污染项目目录
- session 文件存在 `~/.config/yunshu/session.json`,不污染项目目录
### 示例
### 主持者示例type: main
```markdown
---
name: weather-agent
description: 天气情报官
name: dialog
type: main
description: 个人助理,负责闲聊和调度
tools:
- task
- memory.read
- memory.write
---
# 对话助理
你是用户的私人助理...
你可以调度以下子 Agent
- weather: 天气查询
- earthquake: 地震信息
用 task("agent_name", {args}) 调度。
不自己回答领域问题。
```
### 子 Agent 示例type: sub
```markdown
---
name: weather
type: sub
description: 天气查询专家
cache:
ttl: 7200
keys: ["city", "forecast_type"]
tools:
- http-get
- geocode
- skill
---
# 天气情报官
你是专业的天气情报官
# 天气专家
你是天气领域的专家。被调时才回答
## 工作流程
1. 识别城市 → 调用 geocode 获取坐标
2. 调用 skill("msn-weather-api") 获取 API 参数
3. 调用 http-get 请求天气数据
4. 分析并输出
被调时你会收到:
- args: 查询参数
- cache_data: 上次缓存的原始数据(有则传,无则 null
有 cache_data 且未过期 → 直接回答,不使用 http-get。
无 cache_data → 调 http-get 获取新数据。
返回格式(两段式):
---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`
- 启动时清空,每轮对话追加
@@ -65,10 +107,57 @@ tools:
## 工具注册规范
- 工具在 `tool.go``init()` 中通过 `RegisterTool()` 注册
- 每个工具定义Name, Description, ParametersJSON Schema, Execute 函数
- 工具在 `tool.go``init()` 中通过 `RegisterTool()` + `NewTool[T]()` 注册
- `NewTool[T any](name, desc string, fn func(T) (string, error))` 泛型函数自动处理一切:
-`toolschema.go``structToSchema` + `typeToSchema`)反射推导 JSON Schema
- JSON 往返桥接:`map[string]any` ↔ 类型安全的输入结构体
- handler 内直接访问结构体字段,无需 `args["x"].(string)` 类型断言
- 注册路径:`tool.go:init()``NewTool[T]()``RegisterTool()`
- 工具名与 `.md` 文件中声明的 tools 列表对应
- Execute 函数接收 `map[string]interface{}` 参数,返回 string 和 error
- 新增工具三步定义输入结构体struct tags: json, description, enum→ 写 handler → 调用 `NewTool[T]()`
## 日志系统
日志系统分两层:**控制台输出**charmbracelet/log → stderr和**文件持久化**YAML 序列 → `log.yml`)。
### 日志写入
```go
warnLog(msg string, keyvals ...any) // 写 log.yml + 可选 stderr
errorLog(msg string, keyvals ...any) // 同上
infoLog(msg string, keyvals ...any) // 同上
```
- stderr 输出受 `logToStderr` 全局开关控制(交互模式默认关,`/log on` 开启)
- `log.yml` 始终写入YAML 序列格式,每次读→追加→重写
### 日志查看
`yunshu log` 子命令:
```
yunshu log → 全量显示(时间倒序)
yunshu log --top N → 只看最后 N 条
yunshu log --level warn → 过滤级别
yunshu log --clear → 清空
yunshu log --watch → 监听模式2s 轮询)
```
### 日志点
```
[INFO] task(weather) 开始
[INFO] 子 Agent 开始 agent=weather
[INFO] LLM 调用完成 tokens=1420 duration=3.2s
[WARN] 解析 session.json 失败 err=...
```
关键原则:
- 成功日志用 `infoLog`task 开始/完成、LLM 调用完成、会话清空)
- 异常日志用 `warnLog`(解析失败、文件不存在的异常错误)
- 错误日志用 `errorLog`(工具执行失败、子 Agent 未找到)
---
## 环境变量
@@ -79,7 +168,7 @@ tools:
| `LLM_MODEL` | 否 | 模型名,覆盖配置文件 |
| `OPENAI_API_KEY` | 否 | 兼容旧名,当 `LLM_API_KEY` 未设置时生效 |
> *注:可在 `~/.config/yunshu/config.yaml` 中配置,无需环境变量。
> *注:可在 `~/.config/yunshu/config.yml` 中配置,无需环境变量。
> 首次使用请运行 `yunshu onboard` 交互式初始化。
---
@@ -127,3 +216,41 @@ tools:
2. **ANSI 光标移动需要启用输出 VT 处理**`\033[A`(光标上移)/ `\033[J`(清屏)等输出序列需要输出句柄设置 `ENABLE_VIRTUAL_TERMINAL_PROCESSING`0x0004才能生效。只设置输入句柄的 mode 不够,输入输出句柄是两个独立的控制台句柄。
3. **ReadLineWithCompletion / Completer 类型已移除**`completer.go` 清空,`main.go` 回到 `termui.ReadLine()``cmdCompleter``commonPrefix`、相关测试一并移除。`input.go``ReadConsoleInputW` 相关的 `keyEventRecord`/`inputRecord` 结构和 proc 也一并清理。
### 2026-05-11
1. **MSN 天气存在 `hourlyforecast` 端点**`https://assets.msn.cn/service/weather/hourlyforecast`,返回未来 10 天逐小时预报数据,参数和 dailyforecast 一致。之前文档遗漏了该端点。`weathertrends` 端点已失效500 Internal Server Error
2. **MSN 的 `assets.msn.cn` 国内城市接口正常**:用经纬度查询 `assets.msn.cn/service/weather/current` 对国内城市返回正确数据。之前 Agent 误报"返回也门数据"是因为用了 `api.msn.cn` 的城市名接口(该接口本就有文档标注的问题:城市名匹配不可靠)。**用 `assets.msn.cn` + 经纬度即可正常获取国内城市数据,不需要切换到 wttr.in。**
3. **会议室架构决策**:确定从单 Agent 升级为 1 主持dialog, type:main+ N 领域专家type:sub+ 共享黑板memory的架构。主持者保持极薄只有人格 + 调度规则 + `task` + `memory` 工具),子 Agent 只做领域工作,用完即毁。详见 `docs/会议室架构计划书.md`
4. **Cache 设计原则**Frontmatter 声明 `cache.keys``cache.ttl``task` 工具机械化从 args 取值拼 key、查/写缓存。子 Agent 不感知缓存存在,只需在回答末尾可选带 `---CACHE---` + JSON 供 task 存储。一个 Agent 一个缓存 JSON 文件MD5 hash 做 key。
5. **记忆系统规则**:共享黑板模式,所有 Agent 可读,仅 memory Agent 可写。dialog-agent 是最小写入者(只写 `dialog_context`memory Agent 负责从对话中提取用户画像写入长期记忆。
6. **子 Agent 返回协议 `---RESULT---` / `---TEXT---` 双段设计**:子 Agent 回答分两段——RESULT 是原始结构化数据(进缓存,不进 dialog 上下文TEXT 是陈述文本(进 dialog 上下文,由 dialog 用自己的风格输出)。这样 dialog 不会产生"复述感",子 Agent 的数据也保持干净。
7. **两次 LLM 调用架构**:用户提问 → dialog 的 LLM 判断 → `task` 工具调子 Agent 的 LLM第一次→ 子 Agent 返回 TEXT → dialog 的 LLM 用自己的风格说出去(第二次)。各司其职,职责单一。
8. **`task` 工具内部调用 `RunSubAgent()` 实现子 Agent 执行**`RunSubAgent` 不读写全局 session.json子 Agent 的 tool_calls 用完即毁,不污染主上下文。返回后由 `task` 工具解析 `---RESULT---` / `---TEXT---`
### 2026-05-16
1. **charmbracelet/log v2`charm.land/log/v2`)用作结构化日志**。输出到 stderr 时自动带时间戳和级别前缀彩色渲染。v2 模块路径改为 `charm.land/log/v2`(非 `github.com/charmbracelet/log/v2`)。`log.NewWithOptions(w, opt)` 创建实例,方法签名:`log.Warn(msg, keyvals...)``log.Error(msg, keyvals...)``log.Info(msg, keyvals...)`
2. **log.yml 使用 YAML 序列追加策略**。每次写入先读(反序列化为 `[]logEntry`)→ 追加 → 重写(`yaml.Marshal`)。不是 JSONL不是纯文本。适用于 CLI 工具的日志量级。`readLogs()` 返回全部条目用于 `yunshu log` 展示。
3. **`RunSubAgent``maxToolCalls=2` 安全上限**。子 Agent 的 LLM 循环中如果连续 2 轮都在调工具,第 3 轮强制返回 `---TEXT---\n子 Agent 执行轮次超限,已终止)`。防止 prompt 设计缺陷导致子 Agent 死循环。
4. **单 Agent 查询性能优化**。dialog-agent 的 prompt 明确指示:对于只需调一个子 Agent 的查询,跳过 observation 写入和 summary 写入,直接输出子 Agent 结果。综合查询的 observation + summary 合并在同一轮 memory.write 调用。实测 note 保存从 ~90s 降至 ~10s。
5. **路径安全使用 `filepath.EvalSymlinks` + `filepath.Rel` 三段校验**Clean → Join → EvalSymlinks → Rel。防止符号链接绕过路径限制。仅 `strings.Contains("..")` 不够,因为符号链接可以指向 ConfigDir 之外。`os.IsNotExist` 时跳过 EvalSymlinks新文件创建场景
6. **`flag.ExitOnError` 用于 `yunshu log` 子命令 flag 解析**。遇到未知 flag 自动 exit(2) 并打印用法,比手动处理 flag 更简洁。与 `main.go` 的主 args 手动解析配合不冲突。
7. **流式输出使用 SSE 协议OpenAI 兼容格式**。请求加 `"stream": true`,响应逐行 `data: {...}`,以 `data: [DONE]` 结尾。用 `bufio.Reader.ReadString('\n')` 逐行读取,不走 `io.ReadAll`。流式和非流式模式下 tool_calls 的表现不同:非流式一次返回完,流式按 index 分片到达,需要 `accumulatedToolCall` 累积合并。
8. **按 `\n\n` 段落边界缓冲后经 mdprint 渲染**。流式 token 是碎片,不能直接过 mdprint后者需要完整 Markdown parse → AST → ANSI。解决方案将内容写入 blockBuf`\n\n` 处切分——之前的是完整 block调用 `mdprint.Print` 渲染到 stdout之后的是不完整残段留在 buffer 继续累积。流结束时 `mdprint.Print(blockBuf)` 刷最后一段。实测 weather 场景每个 section标题/表格/分隔线)依次弹出,延迟 1-3s渲染效果与非流式一致。
9. **流式模式下 model 可能先吐文本再吐 tool_call**。非流式 API 的 model 要么返回 content 要么返回 tool_calls不会同时出现。但流式模式下model 可能先输出一段"思考"文字,然后才决定调工具。这段文字已经显示给用户,之后工具返回又有第二轮 LLM 响应,就造成了"用户看到两段回复"的问题。**修复方式prompt 层面要求 model"先调工具,不要先说话"**,在 dialog-agent.md 中新增"流式输出原则"章节。

View File

@@ -19,13 +19,14 @@
| 接口 | URL | 功能 | 稳定性 |
|------|-----|------|--------|
| **当前天气** | `https://assets.msn.cn/service/weather/current` | 获取实时天气 | ✅ 稳定可用 |
| **每日预报** | `https://assets.msn.cn/service/weather/dailyforecast` | 未来10天预报 | ✅ 稳定可用 |
| **天气趋势** | `https://assets.msn.cn/service/weather/weathertrends` | 历史+趋势+日历 | ✅ 可用(参数复杂) |
| **当前天气** | `https://assets.msn.cn/service/weather/current` | 获取实时天气+nowcasting | ✅ 稳定可用 |
| **逐小时预报** | `https://assets.msn.cn/service/weather/hourlyforecast` | 未来10天逐小时预报 | ✅ 稳定可用(新增) |
| **每日预报** | `https://assets.msn.cn/service/weather/dailyforecast` | 未来10天每日汇总 | ✅ 稳定可用 |
| **天气趋势** | `https://assets.msn.cn/service/weather/weathertrends` | 历史+趋势+日历 | ❌ 已失效500错误 |
| api.msn.cn 当前 | `https://api.msn.cn/weather/current` | 用城市名获取 | ✅ 可用(但城市名不准) |
| api.msn.cn 预报 | `https://api.msn.cn/weather/forecast` | 预报 | ❌ 500错误 |
**推荐**:只用 `assets.msn.cn`个接口即可满足大部分需求。
**推荐**:只用 `assets.msn.cn`个接口current + hourlyforecast + dailyforecast即可满足大部分需求。
---
@@ -90,6 +91,16 @@ foreach ($day in $days) {
$d = $day.daily
Write-Host " $($d.valid.ToString().Substring(0,10)): $($d.tempLo)-$($d.tempHi)C, 降水$($d.precip)%, 风速$($d.windMax)km/h"
}
# 获取逐小时预报(今天剩余小时 + 后续几天)
$uri_hourly = "https://assets.msn.cn/service/weather/hourlyforecast?apiKey=$apiKey&lat=39.904172&lon=116.407417&units=C&locale=zh-cn"
$hourlyResp = Invoke-RestMethod -Uri $uri_hourly -Headers $headers
$todayHourly = $hourlyResp.value[0].responses[0].weather[0].days[0].hourly
Write-Host "`n今天逐小时预报:"
foreach ($h in $todayHourly) {
Write-Host " $($h.valid.ToString("HH:mm")): $($h.temp)C, $($h.cap), 体感$($h.feels)C, 降水$($h.precip)%, 湿度$($h.rh)%, 风速$($h.windSpd)km/h"
}
```
### curl 示例
@@ -104,6 +115,11 @@ curl -H "User-Agent: Mozilla/5.0" \
curl -H "User-Agent: Mozilla/5.0" \
-H "Referer: https://www.msn.com/zh-cn/weather" \
"https://assets.msn.cn/service/weather/dailyforecast?apiKey=j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo&lat=39.904172&lon=116.407417&units=C&locale=zh-cn&days=7"
# 逐小时预报(今天剩余+未来几天)
curl -H "User-Agent: Mozilla/5.0" \
-H "Referer: https://www.msn.com/zh-cn/weather" \
"https://assets.msn.cn/service/weather/hourlyforecast?apiKey=j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo&lat=39.904172&lon=116.407417&units=C&locale=zh-cn"
```
---
@@ -180,6 +196,52 @@ curl -H "User-Agent: Mozilla/5.0" \
}
```
### hourlyforecast 接口响应
```json
{
"@odata.context": "api.msn.com/weather/$metadata#hourlyforecast",
"value": [{
"responses": [{
"weather": [{
"days": [
{
// days[0] = 今天从当前小时开始到23点
// days[1..9] = 未来9天每天24个整点
"hourly": [
{
"valid": "2026-05-11T07:00:00+08:00", // 时间
"temp": 19.0, // 温度 °C
"feels": 23.0, // 体感温度 °C
"cap": "晴", // 天气描述
"precip": 0.0, // 降水概率 %
"rh": 61.0, // 相对湿度 %
"baro": 1009.0, // 气压 hPa
"windSpd": 4.0, // 风速 km/h
"windDir": 355, // 风向(度)
"windGust": 18.0, // 阵风 km/h
"uv": 1.0, // 紫外线指数
"cloudCover": 6.0, // 云量 %
"vis": 10.0, // 能见度 km
"dewPt": 11.0, // 露点 °C
"rainAmount": 0.0, // 降雨量 mm
"snowAmount": 0.0, // 降雪量 mm
"icon": 1, // 图标代码
"symbol": "d000", // 天气符号
"sky": "CLR" // 天空状况代码
}
// ... 更多小时
]
}
]
}]
}]
}]
}
```
> **注意**`days[0].daily` 为 null今天尚未结束逐小时数据从 `days[0].hourly` 获取。
---
## 六、多城市验证结果
@@ -246,15 +308,21 @@ function Get-MSNWeather {
$forecastUri = "https://assets.msn.cn/service/weather/dailyforecast?apiKey=$apiKey&lat=$Lat&lon=$Lon&units=C&locale=$Locale&days=7"
$forecast = Invoke-RestMethod -Uri $forecastUri -Headers $headers
# 逐小时预报
$hourlyUri = "https://assets.msn.cn/service/weather/hourlyforecast?apiKey=$apiKey&lat=$Lat&lon=$Lon&units=C&locale=$Locale"
$hourly = Invoke-RestMethod -Uri $hourlyUri -Headers $headers
return @{
Current = $current.value[0].responses[0].weather[0].current
Forecast = $forecast.value[0].responses[0].weather[0].days
Hourly = $hourly.value[0].responses[0].weather[0].days
}
}
# 使用示例
$weather = Get-MSNWeather -Lat 39.904172 -Lon 116.407417
$weather.Current.temp # 当前温度
$weather.Hourly[0].hourly # 今天逐小时数据
```
---
@@ -290,8 +358,15 @@ http://img-s-msn-com.akamaized.net/tenant/amp/entityid/AAehR3S.img
| 是否有免费 API | ✅ 有(非公开内部接口) |
| 国内速度 | ✅ 快msn.cn 国内节点) |
| 稳定性 | ⚠️ 未知(非官方,随时可能变) |
| 数据完整性 | ✅ 完整(当前+预报+AQI+紫外线 |
| 数据完整性 | ✅ 完整(当前+逐小时+每日预报+AQI+紫外线+nowcasting |
| 推荐用途 | 个人项目、内部工具、原型开发 |
| 不推荐用途 | 商业产品、长期运行服务 |
## 现状更新
**2026-05-11 更新:**
- `weathertrends` 接口已失效500 Internal Server Error
- 新发现 `hourlyforecast` 接口,提供未来 10 天逐小时预报数据,与 `current``dailyforecast` 同样稳定
- 云图(卫星/雷达)无 REST API 可用MSN 网页使用 tile 图片服务(`assets.msn.com/weathermapdata/`),不适合程序化调用
**建议**如果用于生产环境推荐同时准备备用方案如和风天气、OpenWeatherMap 等)。

View File

@@ -60,24 +60,114 @@ pkg/
└── termui/ 终端交互(行输入、模式设置)
```
## 项目文件
```
main.go CLI 入口onboard/help/version/log 子命令)
types.go 核心类型AgentDef, Schema, ToolDef, Message…
loader.go .md 解析frontmatter + body
catalog.go CatalogAgent 生成 + tools.yml 输出
registry.go AgentRegistryScanAgents, GetMain, GetSub…
llm.go LLM API 封装(豆包/OpenAIsync.Once 延迟加载)
tool.go 工具注册 + safeMemoryPath + ExecuteTool + 7 个工具 handler
toolschema.go 泛型+反射工具注册NewTool[T], structToSchema
runtime.go RunAgent + RunSubAgentmaxToolCalls=2+ cache + session
logger.go charmbracelet/log v2 全局实例(→ stderr
log.go 双写 wrapperwarnLog/errorLog/infoLog+ log.yml + yunshu log 命令
```
## 当前 tools
| 工具名 | 作用 | 实现 |
|--------|------|------|
| http-get | HTTP GET 请求 | Go |
| skill | 按需加载知识 | Go |
| geocode | 城市名 → 坐标 | Go(调 wttr.in |
| read-file | 读取文件 | Go |
| 工具名 | 作用 | 注册方式 |
|--------|------|---------|
| http-get | HTTP GET 请求 | `NewTool[HTTPGetInput]` |
| skill | 按需加载知识 | `NewTool[SkillInput]` |
| geocode | 城市名 → 坐标(调 wttr.in | `NewTool[GeocodeInput]` |
| read-file | 读取文件 | `NewTool[ReadFileInput]` |
| task | 调度子 Agent含缓存管理 + 多步骤编排) | `NewTool[TaskInput]` |
| memory.read | 读取 config/session/notes 等记忆文件 | `NewTool[MemoryReadInput]` |
| memory.write | 写入记忆文件(.md 按 ## 标题合并,.yml 按 key 合并) | `NewTool[MemoryWriteInput]` |
## 后续演进
所有工具都通过 `NewTool[T]` 泛型函数注册,输入结构体自动反射生成 JSON Schemahandler 内参数为类型安全的结构体字段。
## 核心流程
```
云枢·Agent (三层分离+单agent)
用户输入
河虾 claw (三层分离+主-从)
├─ master: 意图识别+任务分发
├─ weather-subagent
├─ tts-subagent
├─ asr-subagent
...更多 subagent
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天气) ✅
├── 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汇报员/成熟期) ❌ 待实现
```
## 存储结构
```
~/.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/AGENTS.md` — 编码规范
- `docs/changelog.md` — 变更日志
- `docs/taolun.md` — 讨论历史

View File

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

View File

@@ -242,3 +242,125 @@ Markdown 渲染器完成 AST 解析后,需要确定标题的终端展示风格
### 验证
- 19/19 单元测试通过
- 构建成功,二进制运行正常
---
## 2026-05-11 会议室架构:从单 Agent 到主-从调度
### 背景
用户发现 `weathertrends` 接口失效,逐小时数据缺失。在排查中意外发现 `hourlyforecast` 端点存在且正常工作,文档之前遗漏了。同时回顾了与天气 Agent 的对话,发现 Agent 汇报 MSN 接口"国内城市不可用"的判断有误——实际 `assets.msn.cn` 按经纬度查一直正常Agent 用的是 `api.msn.cn` 城市名接口(其文档本就标注"北京返回了也门萨那")。
这暴露了单 Agent 架构的局限性Agent 的自我判断不可靠,上下文一多就容易出偏差。
### 讨论历程
#### 从"PicoClaw 为什么崩"出发
| PicoClaw 痛点 | 原因 |
|--------------|------|
| 上下文污染 | 所有知识/工具/历史全混在同一个 system prompt |
| Skill 污染 | skill 内联大量技术细节,退化为长 prompt |
| 逃避执行 | LLM 倾向"自己回答"而非调工具 |
| 扩展困难 | 加能力 = 改代码或改长 prompt |
用户想从 0 实现一个干净的架构,先在云枢上验证,再移植到 HxClaw河虾Claw
#### 方案演进
| 轮次 | 方案 | 问题 |
|------|------|------|
| 1 | CLI 切换主 Agent`--agent weather/earthquake` | 家庭用户记不住命令,跨域查询(火山附近天气)没法做 |
| 2 | 唯一对话入口 + task 调度子 Agent | 但担心记忆管理和上下文混乱 |
| 3 | 命名空间隔离记忆 | 无法共享用户画像(住通州 → 查地震也要知道住通州) |
| 4 | **会议室模式**(最终方案) | 共享黑板(记忆)+ 主持者 + 发言人,角色隔离而非数据隔离 |
#### 关键设计决策
1. **主 Agent 即对话 Agent**`type: main`),用户唯一入口,扮演"个人助理"角色
2. **子 Agent**`type: sub`)是领域专家,被 `task` 工具调才说话,不直接面对用户
3. **`task` 工具**负责:加载子 Agent → 查/写缓存 → 调子 Agent LLM → 返回文本
4. **Cache 机制**Frontmatter 声明 `cache.keys``task` 工具机械化拼 key、查/写文件
5. **记忆管理员**`memory` 子 Agent负责从对话中提取用户画像写入记忆数据库
6. **所有子 Agent 回答经过对话 Agent 返回给用户**,保持单一入口
#### 最终角色定义
| Agent | type | 职责 | 工具 | 缓存 |
|-------|------|------|------|------|
| dialog | main | 入口 + 聊天 + 调度 | `task`, `memory.read/write` | 无(本身就是对话历史) |
| weather | sub | 查天气 | `http-get`, `geocode`, `skill` | `keys: [city, forecast_type]`, ttl: 7200 |
| earthquake | sub | 查地震 | `http-get`, `skill` | `keys: [region]`, ttl: 300 |
| memory | sub | 管理画像/长期记忆 | 读 memory.db | 无 |
| narrator | sub成熟期 | 格式化回答 | `memory.read` | 无 |
#### Cache 设计
```json
// ~/.config/yunshu/cache/weather.json
{
"<hash_of_keys>": {
"created_at": "2026-05-11T06:00:00+08:00",
"ttl": 7200,
"data": {...}, // 原始 API 数据
"raw": {"city": "北京", "forecast_type": "today"} // 原始参数
}
}
```
- 子 Agent 每次回答末尾可选带 `---CACHE---` + JSON只在数据更新时带
- `task` 工具查缓存HIT → 把 `cache.data` 作为 `cache_data` 传给子 AgentMISS → 子 Agent 自己查 API
- 一个 Agent 一个缓存 JSON 文件,里面一个 mapkey 是 hash
#### 会话存储
| 类型 | 位置 | 内容 | 生命周期 |
|------|------|------|---------|
| 对话历史 | `session.json` | 只存 user <-> dialog 的消息 | 每次启动清空 |
| 子 Agent 内部 | 临时 | tool_calls、LLM 调用 | 用完即毁 |
| 长期记忆 | 记忆数据库 | 用户画像、偏好、异常记录 | 持久 |
#### 对比结论
| | PicoClaw | 新方案 |
|---|---|---|
| 架构 | 单 Agent 全能 | 1 主持 + N 领域专家 |
| 上下文 | 全混在 system prompt | Host 只有人格+调度Sub 只有领域 |
| 扩展 | 改代码或改长 prompt | 加一个 `.md` 文件 |
| 记忆 | 无 | 共享黑板Memory Agent 管写入 |
| 工具污染 | 所有工具混在一起 | 按角色过滤 |
| 失败影响 | 一个坏 tool_call 可能污染全部 | 子 Agent 用完即毁 |
---
## 2026-05-16 多步骤编排与文档更新
### 背景
用户提出真正想要的场景是"我要去北京" → 主 Agent 自动理解需要查天气、交通、住宿,依次调不同子 Agent综合回答。当前 runtime 用 `capturedOutput` 覆写机制,子 Agent 结果被截走,主 Agent 没机会继续推理。
### 讨论:跨 Agent 数据传递
用户提出opencode 可以在同上下文中切换不同 agent 继续工作YunShu 是否也可以?
经过分析,结论:
- `task` 工具调用子 Agent → 子 Agent 返回 TEXT → 普通工具响应,可以留对话上下文
- 主 Agent 看到结果后,可以决定再调另一个子 Agent也可以直接回答
- **唯一需要改的**:砍掉 `capturedOutput` 覆写,让 LLM 的自然循环接管
### 决定:移除 capturedOutput启用多步骤编排
`runtime.go` 去掉约 5 行代码(`capturedOutput` 变量、覆写逻辑),然后:
- 主 Agent 的 LLM 可以连续调多个子 Agentweather → train → hotel
- 数据在步骤间通过对话上下文自然传递
- prompt 告诉 LLM信息不够就继续调够了就综合回答
- 单步骤查询只要天气行为不变——LLM 按指令直接输出子 Agent 结果
### 同步更新文档
| 文档 | 更新内容 |
|------|---------|
| `docs/architecture.md` | 工具列表、核心流程图、当前状态树 |
| `docs/会议室架构计划书.md` | 实现状态标记、多步骤编排章节、文件结构、调用示例 |
| `docs/AGENTS.md` | 工具注册规范改为 `NewTool[T]` 方式 |
| `docs/changelog.md` | 新增 2.0.0 版本日志 |
| `docs/taolun.md` | 追加本次讨论 |

View File

@@ -0,0 +1,535 @@
# 云枢·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
---
## 一、架构总览
```
用户
┌──────▼──────────────────────────────────────────┐
│ 主持者dialog-agenttype: main │
│ 人格 + 调度规则 + task + memory 工具 │
│ 唯一入口,用户只和它对话 │
│ ✨ 可以连续多次调不同子 Agent综合数据后回答 │
└──────┬───────────────────────────────────────────┘
│ task("weather", {city: "北京"}) ← 第一步
│ ← 北京明天 5°C 晴
│ task("train", {city: "北京", date: "明天"}) ← 第二步(看到天气后决定)
│ ← G102 08:00 ¥680
│ task("hotel", {city: "北京", nights: 3}) ← 第三步
│ ← 建国饭店 ¥500/晚
│ → 综合: "明天北京5°C…G102早8点…建国饭店…" ← 最终回答
┌───────────────────────────────────────────────┐
│ 发言人(领域子 Agenttype: sub │
│ weather / earthquake / memory / narrator │
│ 被调才说话,返回文本 + 可选缓存数据 │
│ 各自的 cache / skills / tools 互相隔离 │
│ 不感知其他子 Agent 存在,结果由主 Agent 整合 │
└───────────────────────────────────────────────┘
│ 读写
┌───────────────────────────────────────────┐
│ 记录者(记忆系统) │
│ 共享黑板:用户画像、偏好、异常记录 │
│ memory Agent 负责从对话中提取有价值信息 │
│ 所有 Agent 只读,仅 memory Agent 写入 │
└───────────────────────────────────────────┘
```
---
## 二、角色定义
### 2.1 主持者dialog-agenttype: main
**职责**
- 用户的唯一入口
- 有血有肉的个人助理,能闲聊
- 识别用户意图,**可以连续多次**调度不同的子 Agent
- 每次 task() 返回后,决定继续调下一个还是综合回答(多步骤编排)
- 把上一步子 Agent 的结果作为上下文,传递给下一次 task() 的参数
- 读/写记忆(用户画像、上下文摘要)
**工具列表**
- `task` — 调度子 Agent
- `memory.read` — 读长期记忆
- `memory.write` — 写长期记忆
**System Prompt 包含**
- 人格(从 `memory.personality` 加载)
- 调度规则(何时调哪个子 Agent
- 不含任何领域知识
**System Prompt 不包含**
- 天气知识、地震知识等
- `http-get``geocode` 等具体工具
**Session**
- `session.json` 只存 user ↔ dialog 的对话轮次
- 子 Agent 内部的 tool_calls 不写入
### 2.2 发言人weather-subtype: sub
**职责**
- 响应天气查询
-`task` 调才执行,不直接面对用户
- 返回显示文本 + 可选缓存数据
**工具列表**`http-get`, `geocode`, `skill`
**Frontmatter**
```yaml
name: weather
type: sub
description: 天气查询专家
cache:
ttl: 7200
keys: ["city", "forecast_type"]
tools:
- http-get
- geocode
- skill
```
### 2.3 发言人earthquake-subtype: sub预留
**职责**:响应地震信息查询
**Frontmatter**
```yaml
name: earthquake
type: sub
description: 地震信息查询
cache:
ttl: 300
keys: ["region", "time_range"]
tools:
- http-get
- skill
```
### 2.4 记录者memory-subtype: sub
**职责**
- 阅读对话历史,提取用户画像
- 把有价值的信息写入长期记忆数据库
- 响应其他 Agent 的记忆查询
- 记录子 Agent 的异常(如 API 失效)
**工具列表**`memory.read`, `memory.write`, `read-file`, `write-file`
**Frontmatter**
```yaml
name: memory
type: sub
description: 记忆管理员
tools:
- memory.read
- memory.write
- read-file
- write-file
```
### 2.5 汇报员narrator-subtype: sub成熟期
**职责**:把结构化数据翻译成个性化回答
```yaml
name: narrator
type: sub
description: 个性化回答生成器
tools:
- memory.read
```
---
## 三、核心工具task
### 3.1 职责
```
task(agent_name, arguments)
├── 1. 加载 {agent_name}-sub.md Frontmatter
│ ├── name, type, tools, cache
│ ├── cache.keys → ["city", "forecast_type"]
│ └── cache.ttl → 7200
├── 2. 拼缓存 key
│ ├── 遍历 cache.keys → 从 arguments 提取值
│ ├── 拼接 → "city=北京&forecast_type=today"
│ └── hash → "a1b2c3d4e5f6"
├── 3. 读缓存文件 ~/.config/yunshu/cache/{agent_name}.json
│ ├── HIT → cache_data = {temp: 25, ...}
│ └── MISS → cache_data = null
├── 4. 调子 Agent LLM
│ ├── system = {agent_name}-sub.md 内容
│ ├── user = {
│ │ "args": arguments,
│ │ "cache_data": cache_data // 有缓存传数据,没有传 null
│ │ }
│ └── 子 Agent 返回文本 + 可选 ---CACHE--- + JSON
├── 5. 处理子 Agent 返回
│ ├── 有 ---CACHE--- → 提取后面的 JSON → 写缓存
│ └── 无 ---CACHE--- → 只传文本
└── 6. 返回显示文本给 Hostdialog Agent 的 LLM
```
### 3.2 缓存文件格式
```json
// ~/.config/yunshu/cache/{agent_name}.json
{
"<hash>": {
"created_at": "2026-05-11T06:00:00+08:00",
"ttl": 7200,
"data": {
"temp": 25,
"condition": "晴"
},
"raw": {
"city": "北京",
"forecast_type": "today"
}
}
}
```
- hash 由 `cache.keys``arguments` 中提取值 → 拼接 → SHA256 取前 12 位
- `raw` 存原始参数,方便调试和遍历
- 每次读缓存时惰性清理过期条目
### 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
{
"args": {
"city": "北京",
"forecast_type": "today",
"units": "C"
},
"cache_data": {
"temp": 25,
"condition": "晴"
}
}
```
- `args` 是 dialog 传过来的原始参数
- `cache_data` 是缓存的数据(有缓存时),子 Agent 据此直接回答,省一次 API 调用
- 两者都是原始数据,不是处理过的文本
---
## 四、记忆系统
### 4.1 存储位置
```
~/.config/yunshu/memory.db (或 memory.jsonMVP 阶段)
```
### 4.2 数据模型
```json
{
"personality": "你是个幽默风趣的北京大妞,说话带点贫",
"user_profile": {
"location": "北京通州",
"unit": "C",
"allergies": ["花粉"],
"interests": ["户外"],
"mood_today": null
},
"agent_errors": {
"weather": ["msn_api_500 at 2026-05-11T06:00:00"],
"earthquake": []
},
"dialog_context": {
"last_agent": "weather",
"last_topic": "北京天气",
"summary": "用户问了北京天气"
}
}
```
### 4.3 读写规则
| 操作 | 谁做 | 时机 |
|------|------|------|
| `memory.read` | dialog / 子 Agent | 需要画像时 |
| `memory.write` | 只有 memory Agent | 从对话中提取画像后 |
| `memory.write("dialog_context")` | dialog | 每次回答后 |
### 4.4 memory Agent 的工作流
```
用户: "我住北京通州,最近花粉过敏厉害"
→ dialog 聊天回应
→ dialog: task("memory", {action: "extract", text: "用户说住通州、花粉过敏"})
→ memory: 提取 → memory.write("user_profile.location", "北京通州")
memory.write("user_profile.allergies", ["花粉"])
用户: "今天天气怎么样?"
→ dialog: task("memory", {action: "read_context"}) → 有 location
→ dialog: task("weather", {city: "北京通州"})
```
---
## 五、文件结构
```
yunshu/
├── main.go # CLI 入口
├── 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 # 工具注册 + 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 — 汇报员(成熟期)❌
├── skills/
│ ├── msn-weather-api/SKILL.md
│ └── geocoding/SKILL.md
├── docs/
│ └── 此目录
└── pkg/
├── mdprint/
├── style/
└── termui/
```
### 用户配置目录
```
~/.config/yunshu/
├── 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
│ └── ...
├── data/
│ └── weather/ # 子 Agent 自己的数据目录
└── memory.db # 长期记忆数据库
```
---
## 六、调用流程示例
### 6.1 单子 Agent 查询
```
用户: "北京明天多少度?"
HOSTruntime.go:
1. 加载 dialog-agent.md → system prompt
2. 读 session.json → 恢复上下文
3. 调 LLMsession + system + tools
4. LLM 返回 tool_call: task("weather", {city: "北京", forecast_type: "tomorrow"})
task 工具(子 Agent 调用):
1. 加载 weather-sub.md Frontmatter
→ cache.keys: ["city", "forecast_type"], ttl: 1800
2. 拼 key → "city=北京&forecast_type=tomorrow" → sha256[:6]
3. 查 cache/weather.json → MISS
4. 调子 Agent LLMRunSubAgent隔离的循环
system = weather-sub.md
user = {args: {city: "北京", forecast_type: "tomorrow"}, cache_data: null}
5. 子 Agent 工具链:
├── skill("msn-weather-api") → 接口参数
├── geocode("北京") → (39.9, 116.4)
├── http-get(URL) → JSON
└── 返回:
---RESULT---
{temp: {lo:18, hi:31}, condition: "晴"}
---TEXT---
▪ 北京明天天气
...
6. task 提取 RESULT → 写 cache/weather.json
7. 返回 TEXT 给 HOST
HOSTruntime.go:
1. tool 结果 → 追加到对话 → LLM 再次推理
2. LLM 根据 prompt 指令"子 Agent 输出就是答案"→ 直接输出 TEXT
3. 追加 session.json
4. 显示给用户
```
### 6.2 多步骤编排(新增能力)
```
用户: "去北京出差,明天走,待三天"
HOSTruntime.go:
1. 加载 dialog-agent.md → system prompt
2. 读 session → 恢复上下文
3. 调 LLMsession + system + tools
┌─ 第 1 轮 LLM 推理 ──────────────────────────────┐
│ LLM 决定: 先查天气 │
│ tool_call: task("weather", {city:"北京", │
│ forecast_type:"tomorrow"})
│ → 子 Agent 返回: 北京明天 5°C 晴 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 2 轮 LLM 推理 ──────────────────────────────┐
│ LLM 看到天气结果, 决定查火车票 │
│ tool_call: task("train", {city:"北京", date:"明天"})│
│ → 子 Agent 返回: G102 08:00 ¥680 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 3 轮 LLM 推理 ──────────────────────────────┐
│ LLM 看到天气+车次, 决定查酒店 │
│ tool_call: task("hotel", {city:"北京", nights:3}) │
│ → 子 Agent 返回: 建国饭店 ¥500/晚 │
│ → 工具结果追加到对话 │
└──────────────────────────────────────────────────┘
┌─ 第 4 轮 LLM 推理 ──────────────────────────────┐
│ LLM 觉得信息够了 → 返回文本 │
│ "明天北京5°C记得带外套。G102早8点¥680。 │
│ 建国饭店3晚¥1500。总预算约¥2180。" │
│ → 追加 session.json → 显示给用户 │
└──────────────────────────────────────────────────┘
```
---
## 七、实施阶段 — 当前状态
### 阶段一:基础架构(已完成,超计划完成)
| 步骤 | 文件 | 状态 | 说明 |
|------|------|------|------|
| 1.1 | `types.go` | ✅ | `AgentDef``Type string``Cache *CacheDef``Schema` 替代 `ToolParameter` |
| 1.2 | `loader.go` | ✅ | Frontmatter 解析加 `type``cache` 字段 |
| 1.3 | `registry.go` | ✅ | `ScanAgents()` 扫描按 type 分类,同名覆盖 |
| 1.4 | `tool.go` | ✅ | `task` / `memory.read` / `memory.write` + 4 个原有工具 |
| 1.5 | `runtime.go` | ✅ | `RunSubAgent` + `RunAgent` + cache + session |
| 1.6 | `toolschema.go` | ✅ ✨ | **新增(计划外)** — 泛型+反射 `NewTool[T]` 替代手写 Schema |
| 1.7 | `main.go` | ✅ | `ScanAgents().GetMain("dialog")` 动态注入子 Agent 列表 |
| 1.8 | `agents/dialog-agent.md` | ✅ | 主持者,含多步骤编排指令 |
| 1.9 | `agents/weather-sub.md` | ✅ | 天气子 AgentMarkdown 输出 + 生活建议 |
| 1.10 | — | ✅ ✨ | **多步骤编排(计划外)** — 砍掉 `capturedOutput`,主 Agent 连续调多个子 Agent |
#### 计划外新增内容
1. **泛型+反射工具注册**`toolschema.go`
- `NewTool[T any]()` 泛型构造函数,自动反射推导 JSON Schema
- 输入结构体 + struct tags → 零模板代码的工具注册
- handler 内参数为类型安全的结构体字段,无需 `args["x"].(string)`
2. **多步骤编排**`runtime.go` 改造):
- `capturedOutput` 覆写机制已移除
- 子 Agent 结果作为普通工具响应留在对话上下文
- LLM 可以连续多次调 `task()`,直到信息收集完毕再回答
### 阶段二:记忆系统(待开始)
| 任务 | 文件 | 状态 |
|------|------|------|
| 2.1 memory-sub.md | 记忆管理员 Agent从对话提取画像 | ❌ |
| 2.2 记忆数据库 | 结构化存储(画像、偏好、异常记录) | ❌ |
| 2.3 画像自动提取 | memory Agent 定期从对话中提取有用信息 | ❌ |
### 阶段三:扩展(待开始)
| 任务 | 说明 | 状态 |
|------|------|------|
| 3.1 earthquake-sub | 地震信息查询 | ❌ |
| 3.2 train-sub | 火车票查询 | ❌ |
| 3.3 hotel-sub | 住宿查询 | ❌ |
| 3.4 narrator-sub | 个性化回答生成(成熟期) | ❌ |
---
## 八、与 PicoClaw 的对比
| 维度 | PicoClaw | 云枢·会议室模式 |
|------|----------|----------------|
| 入口 | 单 Agent用户直接对话 | 对话 Agent唯一入口+ 背后一堆子 Agent |
| 上下文 | 所有轮次 + 系统 prompt 混在一起 | session 只存 user↔dialog子 Agent 用完即毁 |
| 知识 | 预置或长 prompt | skill 按需加载 |
| 工具 | 所有工具混着用 | 按角色过滤dialog 只有 task + memory |
| 记忆 | 无 | 共享黑板memory Agent 管写入 |
| 扩展 | 改代码或改 prompt | 加一个 .md 文件 |
| 失败隔离 | 坏 tool_call 可能污染全部 | 子 Agent 独立,坏就坏一个 |
| 用户自定义 | 不可能 | 在 `~/.config/yunshu/agents/` 放 .md 即可 |
---
## 九、设计原则
1. **主持者保持极薄** — 只有人格 + 调度规则,不做领域知识
2. **子 Agent 不自知** — 不知道缓存存在、不管理自己的 session只回答当前问题
3. **机械化的不做 LLM** — 缓存 key 拼装、文件读写都是 Go 代码LLM 不参与
4. **数据隔离** — 子 Agent 的 cache 文件、data 目录互相独立
5. **记忆共享** — 黑板机制,所有 Agent 可读,仅 memory Agent 可写
6. **一个入口** — 用户永远只和 dialog-agent 对话,感受不到子 Agent 的存在

28
go.mod
View File

@@ -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
)

44
go.sum
View File

@@ -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=

327
llm.go
View File

@@ -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)
}
}

249
log.go Normal file
View File

@@ -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...)
}

14
logger.go Normal file
View File

@@ -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,
})

187
main.go
View File

@@ -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
}

View File

@@ -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)
}
}

92
registry.go Normal file
View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -11,6 +11,7 @@ description: MSN 天气 API 详细知识
|------|-----|
| 当前天气 | `https://assets.msn.cn/service/weather/current` |
| 每日预报 | `https://assets.msn.cn/service/weather/dailyforecast` |
| 逐小时预报 | `https://assets.msn.cn/service/weather/hourlyforecast` |
## 必须参数
@@ -21,7 +22,7 @@ description: MSN 天气 API 详细知识
## 可选参数
- `days`: 预报天数(最大 10
- `days`: 预报天数(最大 10,仅 dailyforecast 和 hourlyforecast 可用
## 必须请求头
@@ -49,6 +50,32 @@ value[].responses[].weather[].days[].daily.{
}
```
### hourlyforecast 接口
返回未来 10 天每天逐小时预报(今天从当前小时开始,后续每天 24 个点)。
```
value[].responses[].weather[].days[].hourly[].{
valid, // ISO 时间戳,如 "2026-05-11T07:00:00+08:00"
temp, // 温度 °C
feels, // 体感温度 °C
cap, // 天气描述(中文)
precip, // 降水概率 %
rh, // 相对湿度 %
baro, // 气压 hPa
windSpd, // 风速 km/h
windDir, // 风向(度)
windGust, // 阵风 km/h
uv, // 紫外线指数
cloudCover, // 云量 %
vis, // 能见度 km
dewPt, // 露点 °C
rainAmount, // 降雨量 mm
snowAmount, // 降雪量 mm
icon, symbol, // 天气图标代码
sky, wx // 天空状况代码
}
```
## 注意事项
- 数据源为微软 MSN 天气后台接口
- 国内访问速度快msn.cn 国内节点)

398
tool.go
View File

@@ -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
},
})
))
}

133
toolschema.go Normal file
View File

@@ -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)
},
}
}

View File

@@ -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)
}