package agent import ( "bytes" "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/sdaduanbilei/agent-team/internal/llm" "gopkg.in/yaml.v3" ) type Config struct { Name string `yaml:"name"` Description string `yaml:"description"` Provider string `yaml:"provider"` Model string `yaml:"model"` BaseURL string `yaml:"base_url"` APIKeyEnv string `yaml:"api_key_env"` Skills []string `yaml:"skills"` CanChallenge bool `yaml:"can_challenge"` } type Agent struct { Config Config AgentDoc string // AGENT.md 正文(frontmatter 之后的内容) Soul string // SOUL.md 人设 Dir string // agents// KnowledgeDir string // agents/{team}/knowledge/ 目录路径 UseDBConfig bool // 为 true 时 BuildSystemPrompt 不从文件系统热重载 client *llm.Client } func Load(dir string) (*Agent, error) { agentMD, err := os.ReadFile(filepath.Join(dir, "AGENT.md")) if err != nil { return nil, err } cfg, body, err := parseFrontmatterWithBody(agentMD) if err != nil { return nil, fmt.Errorf("parse AGENT.md: %w", err) } soul, _ := os.ReadFile(filepath.Join(dir, "SOUL.md")) if cfg.Provider == "" { cfg.Provider = "deepseek" } if cfg.APIKeyEnv == "" { cfg.APIKeyEnv = "DEEPSEEK_API_KEY" } client, err := llm.New(cfg.Provider, cfg.Model, cfg.BaseURL, cfg.APIKeyEnv) if err != nil { return nil, err } return &Agent{Config: cfg, AgentDoc: body, Soul: string(soul), Dir: dir, client: client}, nil } func parseFrontmatterWithBody(data []byte) (Config, string, error) { var cfg Config if !bytes.HasPrefix(data, []byte("---")) { return cfg, "", fmt.Errorf("missing frontmatter") } parts := bytes.SplitN(data, []byte("---"), 3) if len(parts) < 3 { return cfg, "", fmt.Errorf("invalid frontmatter") } if err := yaml.Unmarshal(parts[1], &cfg); err != nil { return cfg, "", err } body := strings.TrimSpace(string(parts[2])) return cfg, body, nil } // memoryMaxChars 是注入 prompt 的记忆硬限制(字符数) const memoryMaxChars = 4000 // Memory 智能加载记忆:优先 core.md,然后当月文件,硬限制 4000 字符。 func (a *Agent) Memory() string { memDir := filepath.Join(a.Dir, "memory") if _, err := os.Stat(memDir); err != nil { return "" } var sb strings.Builder // 1. 优先加载 core.md(核心经验) if data, err := os.ReadFile(filepath.Join(memDir, "core.md")); err == nil { sb.Write(data) sb.WriteString("\n") } // 2. 加载当月文件 currentMonth := time.Now().Format("2006-01") + ".md" if data, err := os.ReadFile(filepath.Join(memDir, currentMonth)); err == nil { sb.Write(data) sb.WriteString("\n") } // 3. 硬限制截断 result := sb.String() runes := []rune(result) if len(runes) > memoryMaxChars { result = string(runes[:memoryMaxChars]) + "\n...(记忆已截断)" } return result } // SaveMemory appends/updates a memory file. func (a *Agent) SaveMemory(filename, content string) error { memDir := filepath.Join(a.Dir, "memory") os.MkdirAll(memDir, 0755) return os.WriteFile(filepath.Join(memDir, filename), []byte(content), 0644) } // Chat sends messages and streams response tokens via onToken callback. func (a *Agent) Chat(ctx context.Context, msgs []llm.Message, onToken func(string)) (string, error) { result, err := a.client.Stream(ctx, msgs, onToken) if err != nil { return "", err } return result.Content, nil } // ChatWithUsage 同 Chat 但额外返回 token 用量。 func (a *Agent) ChatWithUsage(ctx context.Context, msgs []llm.Message, onToken func(string)) (string, llm.Usage, error) { result, err := a.client.Stream(ctx, msgs, onToken) if err != nil { return "", llm.Usage{}, err } return result.Content, result.Usage, nil } // ChatWithTools 支持 tool calling 的对话 func (a *Agent) ChatWithTools(ctx context.Context, msgs []llm.Message, tools []llm.Tool, onToken func(string)) (*llm.StreamResult, error) { return a.client.StreamWithTools(ctx, msgs, tools, onToken) } // knowledgeMaxChars 是注入 prompt 的知识库硬限制(字符数) const knowledgeMaxChars = 8000 // Knowledge 读取团队知识库目录下的所有 .md 文件,拼接返回,硬限制 8000 字符。 func (a *Agent) Knowledge() string { if a.KnowledgeDir == "" { return "" } entries, err := os.ReadDir(a.KnowledgeDir) if err != nil { return "" } var sb strings.Builder for _, e := range entries { if strings.HasSuffix(e.Name(), ".md") { data, _ := os.ReadFile(filepath.Join(a.KnowledgeDir, e.Name())) if len(data) > 0 { sb.WriteString("### " + e.Name() + "\n") sb.Write(data) sb.WriteString("\n\n") } } } result := sb.String() runes := []rune(result) if len(runes) > knowledgeMaxChars { result = string(runes[:knowledgeMaxChars]) + "\n...(知识库已截断)" } return result } // CompressMemory 压缩非当月的记忆文件:LLM 摘要后追加到 core.md,原文件移到 archive/。 func (a *Agent) CompressMemory(ctx context.Context) error { memDir := filepath.Join(a.Dir, "memory") entries, err := os.ReadDir(memDir) if err != nil { return err } currentMonth := time.Now().Format("2006-01") + ".md" var toCompress []string for _, e := range entries { name := e.Name() // 只处理月份文件(YYYY-MM.md),跳过 core.md、当月文件、archive 目录 if !strings.HasSuffix(name, ".md") || name == "core.md" || name == currentMonth { continue } // 检查是否是月份格式 if len(name) == 10 && name[4] == '-' { toCompress = append(toCompress, name) } } if len(toCompress) == 0 { return nil } archiveDir := filepath.Join(memDir, "archive") os.MkdirAll(archiveDir, 0755) for _, filename := range toCompress { data, err := os.ReadFile(filepath.Join(memDir, filename)) if err != nil || len(data) == 0 { continue } // LLM 压缩 content := string(data) if len([]rune(content)) > 2000 { content = string([]rune(content)[:2000]) } compressPrompt := fmt.Sprintf(`将以下记忆压缩为 3-5 条最核心的经验教训,每条一行 bullet point,只保留最有价值的内容: %s`, content) msgs := []llm.Message{ llm.NewMsg("system", "你是一个记忆压缩助手。将详细记录压缩为核心经验。"), llm.NewMsg("user", compressPrompt), } summary, err := a.Chat(ctx, msgs, nil) if err != nil || summary == "" { continue } // 追加到 core.md coreFile := filepath.Join(memDir, "core.md") existing, _ := os.ReadFile(coreFile) coreContent := string(existing) if coreContent == "" { coreContent = "# 核心经验\n" } coreContent += fmt.Sprintf("\n## %s 经验摘要\n\n%s\n", strings.TrimSuffix(filename, ".md"), summary) os.WriteFile(coreFile, []byte(coreContent), 0644) // 原文件移到 archive/ os.Rename(filepath.Join(memDir, filename), filepath.Join(archiveDir, filename)) } return nil } // BuildSystemPrompt 构建完整的 system prompt。 // SOUL.md = 人设性格,AGENT.md 正文 = 功能描述/工作流程。 // UseDBConfig=true 时使用内存中的值(来自 SQLite),否则从文件系统热重载。 func (a *Agent) BuildSystemPrompt(extraContext string) string { soul := a.Soul agentDoc := a.AgentDoc if !a.UseDBConfig { // 文件系统热重载(仅在未使用 DB 配置时) if data, err := os.ReadFile(filepath.Join(a.Dir, "SOUL.md")); err == nil { soul = string(data) } if data, err := os.ReadFile(filepath.Join(a.Dir, "AGENT.md")); err == nil { if _, body, err := parseFrontmatterWithBody(data); err == nil && body != "" { agentDoc = body } } } var sb strings.Builder sb.WriteString(soul) if agentDoc != "" { sb.WriteString("\n\n") sb.WriteString(agentDoc) } // 知识库注入(在 memory 之前) if knowledge := a.Knowledge(); knowledge != "" { sb.WriteString("\n\n\n") sb.WriteString(knowledge) sb.WriteString("") } if mem := a.Memory(); mem != "" { sb.WriteString("\n\n\n") sb.WriteString(mem) sb.WriteString("") } if extraContext != "" { sb.WriteString("\n\n") sb.WriteString(extraContext) } return sb.String() }