feat: v2.3.0 流式输出 + 日志系统 + 会议室架构全面升级
- 流式输出: SSE 逐 token 接收, \\n\n\ 段落缓冲后 mdprint 彩色渲染 - 日志系统: charmbracelet/log v2 双写(stderr + log.yml), yunshu log 命令 - 会议室架构: dialog(main) + weather/profile/note(sub) 多 Agent 编排 - 泛型工具注册: NewTool[T] 反射推导 JSON Schema, 类型安全 - 安全加固: safeMemoryPath 三段校验(EvalSymlinks+Rel), maxToolCalls=2 - 性能优化: sync.Once 延迟加载, note 一步完成, obs/summary 合并 - Prompt 适配: 流式输出原则(先调工具不说话), 单 Agent 查询跳过 obs+summary - 文档: AGENTS.md + architecture.md + changelog.md 全部同步至 v2.3.0
This commit is contained in:
398
tool.go
398
tool.go
@@ -6,8 +6,11 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var registeredTools = make(map[string]*ToolDef)
|
||||
@@ -34,13 +37,33 @@ func GetToolDefs(names []string) []ToolDef {
|
||||
return defs
|
||||
}
|
||||
|
||||
func safeMemoryPath(path string) (string, error) {
|
||||
cleanPath := filepath.Clean(path)
|
||||
fullPath := filepath.Join(ConfigDir(), cleanPath)
|
||||
|
||||
realPath, err := filepath.EvalSymlinks(fullPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("路径解析失败: %w", err)
|
||||
}
|
||||
realPath = fullPath
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(ConfigDir(), realPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("路径越界: %s", path)
|
||||
}
|
||||
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
func ExecuteTool(tc ToolCall) (string, error) {
|
||||
td, ok := registeredTools[tc.Function.Name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("未知工具: %s", tc.Function.Name)
|
||||
}
|
||||
|
||||
var args map[string]interface{}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||
return "", fmt.Errorf("解析工具参数失败: %w", err)
|
||||
}
|
||||
@@ -48,36 +71,142 @@ func ExecuteTool(tc ToolCall) (string, error) {
|
||||
return td.Execute(args)
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterTool(&ToolDef{
|
||||
Name: "http-get",
|
||||
Description: "发送 HTTP GET 请求获取数据",
|
||||
Parameters: ToolParameter{
|
||||
Type: "object",
|
||||
Properties: map[string]ToolProperty{
|
||||
"url": {Type: "string", Description: "请求的完整 URL 地址"},
|
||||
"headers": {Type: "string", Description: "请求头,JSON 格式的键值对字符串,如 {\"User-Agent\": \"...\"}"},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
},
|
||||
Execute: func(args map[string]interface{}) (string, error) {
|
||||
url, _ := args["url"].(string)
|
||||
if url == "" {
|
||||
return "", fmt.Errorf("缺少 url 参数")
|
||||
}
|
||||
// ============================================================
|
||||
// 工具输入结构体
|
||||
// ============================================================
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
type HTTPGetInput struct {
|
||||
URL string `json:"url" description:"请求的完整 URL 地址"`
|
||||
Headers map[string]string `json:"headers,omitempty" description:"请求头键值对,如 {\"User-Agent\": \"...\"}"`
|
||||
}
|
||||
|
||||
type SkillInput struct {
|
||||
Name string `json:"name" description:"Skill 名称,如 msn-weather-api"`
|
||||
}
|
||||
|
||||
type ReadFileInput struct {
|
||||
Path string `json:"path" description:"文件路径,相对于项目根目录"`
|
||||
}
|
||||
|
||||
type TaskInput struct {
|
||||
Agent string `json:"agent" description:"子 Agent 名称,如 weather"`
|
||||
Args map[string]any `json:"args" description:"子 Agent 参数对象"`
|
||||
}
|
||||
|
||||
type MemoryReadInput struct {
|
||||
Path string `json:"path" description:"文件路径。config/user.md(画像)、config/soul.md(AI灵魂)、session/dialog.yml(对话摘要)、notes/*.md(备忘录) 等。留空返回可用文件列表"`
|
||||
}
|
||||
|
||||
type MemoryWriteInput struct {
|
||||
Path string `json:"path" description:"文件路径。.md 按 ## 标题合并(value 传字符串);.yml 按 key 合并(value 传对象)"`
|
||||
Value interface{} `json:"value" description:"内容。格式取决于文件类型"`
|
||||
}
|
||||
|
||||
type GeocodeInput struct {
|
||||
City string `json:"city" description:"城市名称,支持中文(如 北京)或英文(如 Beijing)"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具注册
|
||||
// ============================================================
|
||||
|
||||
// mdMerge 按 ## 标题合并 Markdown 文件。
|
||||
// incoming 中的标题覆盖 existing 中同名的,其他段保留。
|
||||
func mdMerge(existing, incoming string) string {
|
||||
if existing == "" {
|
||||
return incoming
|
||||
}
|
||||
if incoming == "" {
|
||||
return existing
|
||||
}
|
||||
|
||||
type section struct {
|
||||
heading string
|
||||
content string
|
||||
}
|
||||
|
||||
parse := func(text string) []section {
|
||||
var secs []section
|
||||
var cur section
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if h, ok := strings.CutPrefix(line, "## "); ok && h != "" {
|
||||
if cur.heading != "" || cur.content != "" {
|
||||
secs = append(secs, cur)
|
||||
}
|
||||
cur = section{heading: h}
|
||||
} else {
|
||||
if cur.content != "" {
|
||||
cur.content += "\n"
|
||||
}
|
||||
cur.content += line
|
||||
}
|
||||
}
|
||||
if cur.heading != "" || cur.content != "" {
|
||||
secs = append(secs, cur)
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
existingSecs := parse(existing)
|
||||
incomingSecs := parse(incoming)
|
||||
|
||||
headingIdx := make(map[string]int)
|
||||
for i, s := range existingSecs {
|
||||
headingIdx[s.heading] = i
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var merged []section
|
||||
|
||||
for _, s := range existingSecs {
|
||||
found := false
|
||||
for _, in := range incomingSecs {
|
||||
if in.heading == s.heading {
|
||||
merged = append(merged, in)
|
||||
seen[in.heading] = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
merged = append(merged, s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, in := range incomingSecs {
|
||||
if !seen[in.heading] {
|
||||
merged = append(merged, in)
|
||||
}
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for i, s := range merged {
|
||||
if i > 0 {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
if s.heading != "" {
|
||||
out.WriteString("## ")
|
||||
out.WriteString(s.heading)
|
||||
out.WriteString("\n")
|
||||
}
|
||||
if strings.TrimSpace(s.content) != "" {
|
||||
out.WriteString(strings.TrimRight(s.content, "\n"))
|
||||
out.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterTool(NewTool[HTTPGetInput]("http-get",
|
||||
"发送 HTTP GET 请求获取数据",
|
||||
func(args HTTPGetInput) (string, error) {
|
||||
req, err := http.NewRequest("GET", args.URL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
if headersStr, ok := args["headers"].(string); ok && headersStr != "" {
|
||||
var headers map[string]string
|
||||
if err := json.Unmarshal([]byte(headersStr), &headers); err == nil {
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
for k, v := range args.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
@@ -95,70 +224,162 @@ func init() {
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), nil
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
},
|
||||
})
|
||||
))
|
||||
|
||||
RegisterTool(&ToolDef{
|
||||
Name: "skill",
|
||||
Description: "加载指定名称的 Skill 知识内容到当前上下文,获取专业知识",
|
||||
Parameters: ToolParameter{
|
||||
Type: "object",
|
||||
Properties: map[string]ToolProperty{
|
||||
"name": {Type: "string", Description: "Skill 名称,如 msn-weather-api"},
|
||||
},
|
||||
Required: []string{"name"},
|
||||
RegisterTool(NewTool[SkillInput]("skill",
|
||||
"加载指定名称的 Skill 知识内容到当前上下文,获取专业知识",
|
||||
func(args SkillInput) (string, error) {
|
||||
return LoadSkill(args.Name)
|
||||
},
|
||||
Execute: func(args map[string]interface{}) (string, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("缺少 name 参数")
|
||||
}
|
||||
return LoadSkill(name)
|
||||
},
|
||||
})
|
||||
))
|
||||
|
||||
RegisterTool(&ToolDef{
|
||||
Name: "read-file",
|
||||
Description: "读取本地文件内容",
|
||||
Parameters: ToolParameter{
|
||||
Type: "object",
|
||||
Properties: map[string]ToolProperty{
|
||||
"path": {Type: "string", Description: "文件路径,相对于项目根目录"},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
Execute: func(args map[string]interface{}) (string, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("缺少 path 参数")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
RegisterTool(NewTool[ReadFileInput]("read-file",
|
||||
"读取本地文件内容",
|
||||
func(args ReadFileInput) (string, error) {
|
||||
data, err := os.ReadFile(args.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
return string(data), nil
|
||||
},
|
||||
})
|
||||
))
|
||||
|
||||
RegisterTool(&ToolDef{
|
||||
Name: "geocode",
|
||||
Description: "查询城市或地点的经纬度坐标,返回 lat/lon/name/country。支持中文城市名(如 北京、上海、成都)和英文名",
|
||||
Parameters: ToolParameter{
|
||||
Type: "object",
|
||||
Properties: map[string]ToolProperty{
|
||||
"city": {Type: "string", Description: "城市名称,支持中文(如 北京)或英文(如 Beijing)"},
|
||||
},
|
||||
Required: []string{"city"},
|
||||
},
|
||||
Execute: func(args map[string]interface{}) (string, error) {
|
||||
city, _ := args["city"].(string)
|
||||
if city == "" {
|
||||
return "", fmt.Errorf("缺少 city 参数")
|
||||
RegisterTool(NewTool[TaskInput]("task",
|
||||
"调度子 Agent 执行领域任务。sub-agent 加载后自动查缓存,有缓存直接返回,无缓存调 LLM + 工具链获取新数据",
|
||||
func(args TaskInput) (string, error) {
|
||||
infoLog("task("+args.Agent+") 开始")
|
||||
registry := ScanAgents()
|
||||
sub := registry.GetSub(args.Agent)
|
||||
if sub == nil {
|
||||
errorLog("未找到子 Agent", "agent", args.Agent)
|
||||
return "", fmt.Errorf("未找到子 Agent: %s", args.Agent)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://wttr.in/%s?format=j1", city)
|
||||
var cacheKey string
|
||||
var cacheData interface{}
|
||||
if sub.Cache != nil && len(sub.Cache.Keys) > 0 {
|
||||
cacheKey = buildCacheKey(sub.Cache.Keys, args.Args)
|
||||
if entry := readCache(args.Agent, cacheKey); entry != nil {
|
||||
cacheData = entry.Data
|
||||
}
|
||||
}
|
||||
|
||||
subInput := map[string]any{
|
||||
"args": args.Args,
|
||||
"cache_data": cacheData,
|
||||
}
|
||||
subInputBytes, _ := json.Marshal(subInput)
|
||||
|
||||
result, err := RunSubAgent(sub, string(subInputBytes))
|
||||
if err != nil {
|
||||
errorLog("task("+args.Agent+") 失败", "err", err)
|
||||
return "", fmt.Errorf("子 Agent %s 执行失败: %w", args.Agent, err)
|
||||
}
|
||||
|
||||
text, resultData := parseSubResult(result)
|
||||
|
||||
if cacheKey != "" && resultData != nil && sub.Cache != nil {
|
||||
writeCache(args.Agent, cacheKey, resultData, args.Args, sub.Cache.TTL)
|
||||
}
|
||||
|
||||
infoLog("task("+args.Agent+") 完成")
|
||||
return text, nil
|
||||
},
|
||||
))
|
||||
|
||||
RegisterTool(NewTool[MemoryReadInput]("memory.read",
|
||||
"读取记忆文件。路径支持 config/, session/, log.yml, notes/ 等。返回文件原始内容",
|
||||
func(args MemoryReadInput) (string, error) {
|
||||
fullPath, err := safeMemoryPath(args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "null", nil
|
||||
}
|
||||
return "", fmt.Errorf("读取失败: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
entries, err := os.ReadDir(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取目录失败: %w", err)
|
||||
}
|
||||
names := make([]string, 0)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
out, _ := yaml.Marshal(names)
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
},
|
||||
))
|
||||
|
||||
RegisterTool(NewTool[MemoryWriteInput]("memory.write",
|
||||
"写入记忆文件。.md 按 ## 标题合并(value 传字符串),.yml 按 key 合并(value 传对象)。目录自动创建",
|
||||
func(args MemoryWriteInput) (string, error) {
|
||||
fullPath, err := safeMemoryPath(args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(fullPath), 0755)
|
||||
|
||||
ext := filepath.Ext(args.Path)
|
||||
switch ext {
|
||||
case ".yaml", ".yml":
|
||||
existing := make(map[string]any)
|
||||
if data, err := os.ReadFile(fullPath); err == nil {
|
||||
if err := yaml.Unmarshal(data, &existing); err != nil {
|
||||
warnLog("解析 yml 失败", "path", args.Path, "err", err)
|
||||
}
|
||||
}
|
||||
if m, ok := args.Value.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
existing[k] = v
|
||||
}
|
||||
}
|
||||
out, err := yaml.Marshal(existing)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化 yml 失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(fullPath, out, 0644); err != nil {
|
||||
return "", fmt.Errorf("写入失败: %w", err)
|
||||
}
|
||||
default:
|
||||
str, ok := args.Value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf(".md 文件 value 必须是字符串")
|
||||
}
|
||||
existing := ""
|
||||
if data, err := os.ReadFile(fullPath); err == nil {
|
||||
existing = string(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
warnLog("读取 md 失败", "path", args.Path, "err", err)
|
||||
}
|
||||
merged := mdMerge(existing, str)
|
||||
if err := os.WriteFile(fullPath, []byte(merged), 0644); err != nil {
|
||||
return "", fmt.Errorf("写入失败: %w", err)
|
||||
}
|
||||
}
|
||||
return "ok", nil
|
||||
},
|
||||
))
|
||||
|
||||
RegisterTool(NewTool[GeocodeInput]("geocode",
|
||||
"查询城市或地点的经纬度坐标,返回 lat/lon/name/country。支持中文城市名(如 北京、上海、成都)和英文名",
|
||||
func(args GeocodeInput) (string, error) {
|
||||
url := fmt.Sprintf("https://wttr.in/%s?format=j1", args.City)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %w", err)
|
||||
@@ -182,15 +403,10 @@ func init() {
|
||||
|
||||
var result struct {
|
||||
NearestArea []struct {
|
||||
AreaName []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"areaName"`
|
||||
Country []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"country"`
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
Population string `json:"population"`
|
||||
AreaName []struct{ Value string `json:"value"` } `json:"areaName"`
|
||||
Country []struct{ Value string `json:"value"` } `json:"country"`
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
} `json:"nearest_area"`
|
||||
}
|
||||
|
||||
@@ -199,7 +415,7 @@ func init() {
|
||||
}
|
||||
|
||||
if len(result.NearestArea) == 0 {
|
||||
return "", fmt.Errorf("未找到城市: %s", city)
|
||||
return "", fmt.Errorf("未找到城市: %s", args.City)
|
||||
}
|
||||
|
||||
area := result.NearestArea[0]
|
||||
@@ -212,7 +428,7 @@ func init() {
|
||||
country = area.Country[0].Value
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
out, _ := json.Marshal(map[string]any{
|
||||
"lat": area.Latitude,
|
||||
"lon": area.Longitude,
|
||||
"name": name,
|
||||
@@ -220,5 +436,5 @@ func init() {
|
||||
})
|
||||
return string(out), nil
|
||||
},
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user