Files
LaodingBot/internal/agent/orchestrator.go

1152 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package agent
import (
"context"
"fmt"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"laodingbot/internal/knowledge"
"laodingbot/internal/llm"
"laodingbot/internal/logger"
"laodingbot/internal/memory"
"laodingbot/internal/tools"
)
// Orchestrator 负责协调和组合业务逻辑,包含 LLM 计算、上下文管理、技能匹配计算和工具调用。
type Orchestrator struct {
llm llm.Client
store *memory.SQLiteStore
tools *tools.Registry
soul string
skills []knowledge.Skill
skillSummaries []knowledge.SkillSummary
skillsDir string
autoSkillDir string
gapDraftTriggerCount int
gapLookbackDuration time.Duration
reactMaxStep int
enableCapabilityGap bool
log *logger.Logger
skillsMu sync.RWMutex
pendingFilesMu sync.Mutex
pendingFiles map[string][]pendingFileRef
}
type pendingFileRef struct {
ID string
Name string
MimeType string
}
type capabilityRoutingResult struct {
NeedSkills bool
SelectedToolNames []string
SelectedSkills []knowledge.Skill
Reason string
UsedFallback bool
}
type filePromptContext struct {
Summary string
FatalReason string
FileIDs []string
Uploaded []pendingFileRef
}
// NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。
func NewOrchestrator(
llmClient llm.Client,
store *memory.SQLiteStore,
registry *tools.Registry,
soul string,
skills []knowledge.Skill,
skillSummaries []knowledge.SkillSummary,
skillsDir string,
reactMaxStep int,
enableCapabilityGap bool,
autoSkillDir string,
gapDraftTriggerCount int,
gapLookbackDuration time.Duration,
log *logger.Logger,
) *Orchestrator {
if reactMaxStep <= 0 {
reactMaxStep = 8 // 默认最大 ReAct 步骤数为 8
}
if gapDraftTriggerCount <= 0 {
gapDraftTriggerCount = 3 // 默认触发技能生成的缺口数量为 3
}
if gapLookbackDuration <= 0 {
gapLookbackDuration = 7 * 24 * time.Hour // 默认回溯时长为 7 天
}
if strings.TrimSpace(autoSkillDir) == "" {
autoSkillDir = skillsDir
}
return &Orchestrator{
llm: llmClient,
store: store,
tools: registry,
soul: soul,
skills: skills,
skillSummaries: copySkillSummaries(skillSummaries),
skillsDir: skillsDir,
autoSkillDir: autoSkillDir,
gapDraftTriggerCount: gapDraftTriggerCount,
gapLookbackDuration: gapLookbackDuration,
reactMaxStep: reactMaxStep,
enableCapabilityGap: enableCapabilityGap,
log: log,
pendingFiles: make(map[string][]pendingFileRef),
}
}
// HandleMessage 是接受用户消息输入并通过统一 ReAct 循环生成回复的主流程。
// 不再分"先选 skill 再决策"两步,而是 LLM 第一次调用就同时决定:
// - 是否可以直接回答is_final_answer=true
// - 是否需要调用工具action + action_input
// 循环持续进行,直到 LLM 返回 is_final_answer=true。
func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text string) (string, error) {
return o.handleMessageInternal(ctx, chatID, userID, text, nil)
}
func (o *Orchestrator) HandleMessageWithFiles(ctx context.Context, chatID, userID, text string, files []llm.InputFile) (string, error) {
return o.handleMessageInternal(ctx, chatID, userID, text, files)
}
func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID, text string, files []llm.InputFile) (string, error) {
// 为链路追踪设置唯一的 TraceID
traceID := logger.NewTraceID()
ctx = logger.WithTraceID(ctx, traceID)
traceLogPrefix := "trace_id=" + traceID
if o.log != nil {
o.log.Infof("%s handle message chat_id=%s user_id=%s text_len=%d files=%d", traceLogPrefix, chatID, userID, len(text), len(files))
o.log.Debugf("%s handle message text=%q", traceLogPrefix, text)
}
// 处理特殊的重载指令
if strings.EqualFold(strings.TrimSpace(text), "/reload_skills") {
if err := o.ReloadSkills(); err != nil {
return "技能热加载失败: " + err.Error(), nil
}
return "技能已热加载完成。", nil
}
// 如果用户请求能力缺口报告,则生成报告格式化输出
if strings.EqualFold(strings.TrimSpace(text), "/capability_gaps") {
report, err := o.BuildCapabilityGapReport(10)
if err != nil {
return "缺口报告生成失败: " + err.Error(), nil
}
return report, nil
}
trimmedText := strings.TrimSpace(text)
isFileOnly := len(files) > 0 && trimmedText == ""
if isFileOnly {
if err := o.store.SaveMessage(chatID, userID, "user", "[FILE_UPLOAD]"); err != nil {
if o.log != nil {
o.log.Errorf("%s save file-only user marker failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
uploadCtx := o.prepareFilePromptContext(ctx, files, nil)
if strings.TrimSpace(uploadCtx.FatalReason) != "" {
finalText := "文件上传失败,无法建立文档上下文。" + "\n" + uploadCtx.FatalReason
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil && o.log != nil {
o.log.Warnf("%s save upload failure message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return finalText, nil
}
o.appendPendingFiles(chatID, userID, uploadCtx.toPendingRefs())
finalText := o.buildFileUploadAck(uploadCtx)
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil {
if o.log != nil {
o.log.Errorf("%s save file upload ack failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
if o.log != nil {
o.log.Infof("%s file-only message handled chat_id=%s cached_files=%d", traceLogPrefix, chatID, len(uploadCtx.FileIDs))
}
return finalText, nil
}
// 保存用户消息到 SQLite 中
if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil {
if o.log != nil {
o.log.Errorf("%s save user message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
// 读取最近的会话记忆并压缩成 Prompt 上下文
recent, err := o.store.LoadRecent(chatID, 16)
if err != nil {
if o.log != nil {
o.log.Errorf("%s load recent failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
compressed := memory.CompressForPrompt(recent, 6000)
if o.log != nil {
o.log.Debugf("%s prompt context prepared chat_id=%s recent_count=%d compressed_len=%d", traceLogPrefix, chatID, len(recent), len(compressed))
}
// 进入统一 ReAct 循环
pendingRefs := o.getPendingFiles(chatID, userID)
fileCtx := o.prepareFilePromptContext(ctx, files, pendingRefs)
if strings.TrimSpace(fileCtx.FatalReason) != "" {
finalText := "文件上传失败,无法继续进行文档解析。" + "\n" + fileCtx.FatalReason
if err := o.store.SaveMessage(chatID, userID, "assistant", finalText); err != nil && o.log != nil {
o.log.Warnf("%s save assistant failure message failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
if o.log != nil {
o.log.Warnf("%s stop before react due to file upload failure reason=%s", traceLogPrefix, fileCtx.FatalReason)
}
return finalText, nil
}
routeInput := composeRouteInput(text, fileCtx.Summary)
route := o.routeCapabilities(ctx, routeInput)
response, err := o.runUnifiedReAct(ctx, chatID, userID, compressed, text, fileCtx, routeInput, route)
if err != nil {
if o.log != nil {
o.log.Errorf("%s message generation failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
if len(pendingRefs) > 0 {
o.clearPendingFiles(chatID, userID)
}
// 最终将机器人的回复也加入记忆缓存
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
if o.log != nil {
o.log.Errorf("%s save assistant response failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
}
return "", err
}
if o.log != nil {
o.log.Infof("%s message handled chat_id=%s response_len=%d", traceLogPrefix, chatID, len(response))
}
return response, nil
}
// buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。
// 工具始终可用;技能仅按当前问题挑选相关项作为增强上下文。
func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, route capabilityRoutingResult) string {
skillMetaDoc := o.formatSkillSummariesForPrompt()
relevantSkillsDoc := o.formatSelectedSkillsForPrompt(userInput, route.SelectedSkills)
toolDoc := o.formatToolDoc()
runtimeDoc := formatRuntimeContextForPrompt()
routeDoc := formatRouteForPrompt(route)
return strings.Join([]string{
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
o.soul,
"",
"===== 运行环境 =====",
runtimeDoc,
"",
"===== 可用技能概览 =====",
skillMetaDoc,
"",
"===== 能力路由结果 =====",
routeDoc,
"",
"===== 本轮相关技能(按用户问题筛选) =====",
relevantSkillsDoc,
"",
"===== 可用工具 =====",
toolDoc,
"",
"===== 输出格式约束 =====",
"你必须使用 ReActReasoning + Acting模式进行决策。",
"每次回复必须是且仅是一个 JSON 对象,字段如下:",
"",
"{",
" \"thought\": \"你的推理过程(必填)\",",
" \"action\": \"要调用的工具名称,如 file/shell/web_search不调工具时填 none\",",
" \"action_input\": \"传给工具的输入(字符串或对象),不调工具时填空字符串或 null\",",
" \"is_final_answer\": true 或 false,",
" \"final_answer\": \"当 is_final_answer=true 时填写给用户的最终回复,否则填 null\"",
"}",
"",
"决策规则:",
"1) 如果你可以直接回答用户问题(不需要任何工具):",
" 设 is_final_answer=trueaction=\"none\"final_answer 填写完整回复。",
"2) 优先判断是否可通过原子工具能力完成任务;若可完成,直接进行工具调用链路。",
"3) 当纯工具调用无法满足时,再结合已加载的技能详细说明进行决策。",
"4) 如果你需要调用工具获取信息后才能回答:",
" 设 is_final_answer=falseaction 填工具名action_input 填工具所需输入final_answer=null。",
"5) 不要在 JSON 之外输出任何内容。",
"6) 根据技能说明中的指引决定何时以及如何使用工具。",
"7) 工具能力是全局可用的,不依赖技能命中;当技能不匹配时,仍可直接选择合适工具。",
"8) 若技能中存在与当前运行环境不匹配的章节(如 Windows 专章),应降低优先级,除非用户明确要求该环境。",
"9) 每轮工具调用结果会以 Observation 的形式追加到推理记录中,供你下一轮决策参考。",
}, "\n")
}
// runUnifiedReAct 执行统一的 ReAct 循环。
// LLM 每次都看到完整的技能集+工具集,自行决定是否调用工具或直接回答。
// 循环持续到 is_final_answer=true 或达到安全上限。
func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, compressedContext, userInput string, fileCtx filePromptContext, routeInput string, route capabilityRoutingResult) (string, error) {
traceID := logger.TraceIDFromContext(ctx)
traceLogPrefix := "trace_id=" + traceID
if strings.TrimSpace(routeInput) == "" {
routeInput = composeRouteInput(userInput, fileCtx.Summary)
}
systemPrompt := o.buildUnifiedSystemPrompt(routeInput, route)
if o.log != nil {
o.log.Infof("%s unified react start route_need_skills=%v route_tools=%v route_skills=%d fallback=%v", traceLogPrefix, route.NeedSkills, route.SelectedToolNames, len(route.SelectedSkills), route.UsedFallback)
}
// 安全上限:防止无限循环(当前暂不使用 reactMaxStep 配置约束,使用固定硬上限)
const maxSteps = 20
scratchpad := ""
for step := 1; step <= maxSteps; step++ {
if o.log != nil {
o.log.Infof("%s react step=%d start", traceLogPrefix, step)
o.log.Debugf("%s react step=%d scratchpad=%q", traceLogPrefix, step, scratchpad)
}
// 构造本轮 user prompt历史上下文 + 用户问题 + 推理记录
prompt := strings.Join([]string{
"历史上下文:",
compressedContext,
"",
"用户问题:",
userInput,
"",
"文件上下文:",
defaultIfEmpty(fileCtx.Summary, "(none)"),
"",
"当前推理记录(按时间顺序):",
scratchpad,
"",
"请输出你的 JSON 决策。",
}, "\n")
raw, err := o.generateWithOptionalFiles(ctx, systemPrompt, prompt, fileCtx.FileIDs)
if err != nil {
return "", err
}
if o.log != nil {
o.log.Infof("%s react step=%d llm_raw=%q", traceLogPrefix, step, raw)
}
// 解析 LLM 返回的 JSON 决策
decision, err := parseDecision(raw)
if err != nil {
if o.log != nil {
o.log.Warnf("%s react step=%d parse failed err=%v, using raw as final answer", traceLogPrefix, step, err)
}
// 解析失败时,尝试将原始输出当作直接回答返回
o.emitCapabilityGap(chatID, userID, userInput, "react_parse_failed")
return strings.TrimSpace(raw), nil
}
if o.log != nil {
o.log.Infof("%s react step=%d thought=%q action=%q is_final=%v",
traceLogPrefix, step, decision.Thought, decision.Action, decision.IsFinalAnswer)
}
// ========== 判定:是否为最终回答 ==========
if decision.IsFinalAnswer {
finalText := ""
if decision.FinalAnswer != nil {
finalText = strings.TrimSpace(*decision.FinalAnswer)
}
if finalText == "" {
finalText = strings.TrimSpace(decision.Thought)
}
if finalText == "" {
finalText = "已完成处理。"
}
if o.log != nil {
o.log.Infof("%s react final at step=%d answer=%q", traceLogPrefix, step, finalText)
}
return finalText, nil
}
// ========== 非最终回答:执行工具调用 ==========
action := strings.ToLower(strings.TrimSpace(decision.Action))
if action == "" || action == "none" {
// LLM 说不是最终回答但也不指定工具,记录后让它再想一轮
scratchpad += "Step " + strconv.Itoa(step) + " Thought: " + decision.Thought + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " Observation: 你没有指定要调用的工具,请重新决策:要么调用工具,要么给出最终回答。\n"
continue
}
actionInput := decision.GetActionInputString()
// 检查工具是否存在
tool, ok := o.tools.Get(action)
if !ok {
if o.log != nil {
o.log.Warnf("%s react step=%d tool_not_found=%s", traceLogPrefix, step, action)
}
scratchpad += "Step " + strconv.Itoa(step) + " Thought: " + decision.Thought + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " Action: " + action + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " Observation: " + formatToolErrorObservation("TOOL_NOT_FOUND", action, "该工具不存在,可用工具请参阅 system prompt") + "\n"
o.emitCapabilityGap(chatID, userID, userInput, "tool_not_found:"+action)
continue
}
// 调用工具
if o.log != nil {
o.log.Infof("%s react step=%d tool_call tool=%s input=%q", traceLogPrefix, step, action, actionInput)
}
toolOut, toolErr := tool.Call(ctx, actionInput)
obs := strings.TrimSpace(toolOut)
if obs == "" {
obs = "(empty output)"
}
if toolErr != nil {
obs = formatToolErrorObservation("TOOL_EXEC_ERROR", action, toolErr.Error()) + "\nOUTPUT:\n" + obs
o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+action)
}
// 限制观察值长度防止超出 LLM 上下文窗口
if len(obs) > 4000 {
obs = obs[:4000] + "\n...(truncated)"
}
if o.log != nil {
o.log.Infof("%s react step=%d observation_len=%d", traceLogPrefix, step, len(obs))
}
// 将本轮的思考、行动、观察追加到 scratchpad
scratchpad += "Step " + strconv.Itoa(step) + " Thought: " + decision.Thought + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " Action: " + action + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " ActionInput: " + actionInput + "\n"
scratchpad += "Step " + strconv.Itoa(step) + " Observation: " + obs + "\n"
}
// 达到安全上限仍未得到最终回答
o.emitCapabilityGap(chatID, userID, userInput, "react_step_exhausted")
return "我尝试了多轮推理与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
}
func composeRouteInput(userInput, fileSummary string) string {
userInput = strings.TrimSpace(userInput)
fileSummary = strings.TrimSpace(fileSummary)
if userInput == "" {
return fileSummary
}
if fileSummary == "" {
return userInput
}
return userInput + "\n\n" + fileSummary
}
func (o *Orchestrator) prepareFilePromptContext(ctx context.Context, files []llm.InputFile, pending []pendingFileRef) filePromptContext {
ctxOut := filePromptContext{}
if len(pending) > 0 {
for _, p := range pending {
id := strings.TrimSpace(p.ID)
if id == "" {
continue
}
ctxOut.FileIDs = append(ctxOut.FileIDs, id)
}
}
if len(files) == 0 {
ctxOut.Summary = buildFileSummary(pending, nil)
return ctxOut
}
uploader, ok := o.llm.(llm.FileUploader)
if !ok {
return filePromptContext{FatalReason: "检测到文件输入,但当前 LLM 客户端不支持文件上传接口。"}
}
uploaded := make([]pendingFileRef, 0, len(files))
for i, f := range files {
if strings.TrimSpace(f.FileName) == "" || len(f.Content) == 0 {
return filePromptContext{FatalReason: fmt.Sprintf("file[%d] 缺少文件名或内容,无法上传。", i+1)}
}
fileID, err := uploader.UploadFile(ctx, f, "file-extract")
if err != nil {
return filePromptContext{FatalReason: fmt.Sprintf("file[%d] name=%s 上传失败: %v", i+1, f.FileName, err)}
}
ctxOut.FileIDs = append(ctxOut.FileIDs, fileID)
uploaded = append(uploaded, pendingFileRef{
ID: fileID,
Name: strings.TrimSpace(f.FileName),
MimeType: defaultIfEmpty(strings.TrimSpace(f.MimeType), "application/octet-stream"),
})
}
ctxOut.Uploaded = uploaded
ctxOut.Summary = buildFileSummary(pending, uploaded)
return ctxOut
}
func buildFileSummary(pending, uploaded []pendingFileRef) string {
if len(pending) == 0 && len(uploaded) == 0 {
return ""
}
lines := make([]string, 0, len(pending)+len(uploaded)+2)
lines = append(lines, "以下文件 file_id 可用于本轮问答:")
idx := 1
for _, p := range pending {
id := strings.TrimSpace(p.ID)
if id == "" {
continue
}
lines = append(lines, fmt.Sprintf("- cached_file[%d] name=%s mime=%s file_id=%s", idx, defaultIfEmpty(strings.TrimSpace(p.Name), "(unknown)"), defaultIfEmpty(strings.TrimSpace(p.MimeType), "application/octet-stream"), id))
idx++
}
for _, p := range uploaded {
id := strings.TrimSpace(p.ID)
if id == "" {
continue
}
lines = append(lines, fmt.Sprintf("- uploaded_file[%d] name=%s mime=%s file_id=%s", idx, defaultIfEmpty(strings.TrimSpace(p.Name), "(unknown)"), defaultIfEmpty(strings.TrimSpace(p.MimeType), "application/octet-stream"), id))
idx++
}
if len(lines) == 1 {
return ""
}
return strings.Join(lines, "\n")
}
func (o *Orchestrator) generateWithOptionalFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error) {
ids := nonEmptyIDs(fileIDs)
if len(ids) == 0 {
return o.llm.Generate(ctx, systemPrompt, userPrompt)
}
client, ok := o.llm.(llm.FileChatClient)
if !ok {
return o.llm.Generate(ctx, systemPrompt, userPrompt)
}
return client.GenerateWithFiles(ctx, systemPrompt, userPrompt, ids)
}
func (o *Orchestrator) buildFileUploadAck(ctx filePromptContext) string {
if len(ctx.FileIDs) == 0 {
return "文件已接收,但未拿到有效 file_id。请重新上传一次。"
}
lines := []string{
fmt.Sprintf("文件上传完成,已缓存 %d 个 file_id。", len(ctx.FileIDs)),
"请继续发送你的问题,我会结合这些文件内容和历史对话一起回答。",
}
if strings.TrimSpace(ctx.Summary) != "" {
lines = append(lines, "", ctx.Summary)
}
return strings.Join(lines, "\n")
}
func nonEmptyIDs(ids []string) []string {
if len(ids) == 0 {
return nil
}
out := make([]string, 0, len(ids))
seen := map[string]struct{}{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (c filePromptContext) toPendingRefs() []pendingFileRef {
if len(c.Uploaded) > 0 {
copied := make([]pendingFileRef, len(c.Uploaded))
copy(copied, c.Uploaded)
return sanitizePendingRefs(copied)
}
ids := nonEmptyIDs(c.FileIDs)
out := make([]pendingFileRef, 0, len(ids))
for _, id := range ids {
out = append(out, pendingFileRef{ID: id})
}
return out
}
func (o *Orchestrator) appendPendingFiles(chatID, userID string, refs []pendingFileRef) {
refs = sanitizePendingRefs(refs)
if len(refs) == 0 {
return
}
key := pendingFileKey(chatID, userID)
o.pendingFilesMu.Lock()
defer o.pendingFilesMu.Unlock()
merged := append(o.pendingFiles[key], refs...)
o.pendingFiles[key] = sanitizePendingRefs(merged)
}
func (o *Orchestrator) getPendingFiles(chatID, userID string) []pendingFileRef {
key := pendingFileKey(chatID, userID)
o.pendingFilesMu.Lock()
defer o.pendingFilesMu.Unlock()
snapshot := o.pendingFiles[key]
out := make([]pendingFileRef, len(snapshot))
copy(out, snapshot)
return out
}
func (o *Orchestrator) clearPendingFiles(chatID, userID string) {
key := pendingFileKey(chatID, userID)
o.pendingFilesMu.Lock()
defer o.pendingFilesMu.Unlock()
delete(o.pendingFiles, key)
}
func pendingFileKey(chatID, userID string) string {
return strings.TrimSpace(chatID) + "::" + strings.TrimSpace(userID)
}
func sanitizePendingRefs(refs []pendingFileRef) []pendingFileRef {
if len(refs) == 0 {
return nil
}
out := make([]pendingFileRef, 0, len(refs))
seen := map[string]struct{}{}
for _, r := range refs {
id := strings.TrimSpace(r.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
r.ID = id
r.Name = strings.TrimSpace(r.Name)
r.MimeType = strings.TrimSpace(r.MimeType)
out = append(out, r)
}
return out
}
func defaultIfEmpty(v, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return v
}
// formatRelevantSkillsForPrompt 返回与当前用户问题最相关的技能内容。
func (o *Orchestrator) formatSelectedSkillsForPrompt(userInput string, selected []knowledge.Skill) string {
skills := selected
if len(skills) == 0 {
skills = o.selectRelevantSkills(userInput, 4)
}
if len(skills) == 0 {
return "(none matched, tools are still globally available)"
}
return formatSkills(skills)
}
func (o *Orchestrator) routeCapabilities(ctx context.Context, userInput string) capabilityRoutingResult {
fallback := capabilityRoutingResult{
NeedSkills: true,
SelectedSkills: o.selectRelevantSkills(userInput, 4),
Reason: "router fallback: keyword matching",
UsedFallback: true,
}
raw, err := o.llm.Generate(ctx, o.buildRouteSystemPrompt(), o.buildRouteUserPrompt(userInput))
if err != nil {
if o.log != nil {
o.log.Warnf("capability router llm call failed err=%v", err)
}
return fallback
}
decision, err := parseCapabilityRoute(raw)
if err != nil {
if o.log != nil {
o.log.Warnf("capability router parse failed err=%v raw=%q", err, raw)
}
return fallback
}
resolvedTools := o.normalizeToolSelection(decision.SelectedTools)
resolved := capabilityRoutingResult{
NeedSkills: decision.NeedSkills,
SelectedToolNames: resolvedTools,
Reason: strings.TrimSpace(decision.Reason),
}
if resolved.NeedSkills {
skills := o.resolveSkillsByNames(decision.SelectedSkills, 4)
if len(skills) == 0 {
skills = o.selectRelevantSkills(userInput, 4)
resolved.UsedFallback = true
}
resolved.SelectedSkills = skills
}
return resolved
}
func (o *Orchestrator) buildRouteSystemPrompt() string {
return strings.Join([]string{
"你是能力路由器Router Agent。",
"你的任务是:在不加载技能全文的前提下,仅根据工具摘要和技能摘要,判断本请求是否可以仅靠原子工具能力完成,还是需要加载技能详细说明。",
"输出必须且仅能是 JSON",
"{",
" \"need_skills\": true 或 false,",
" \"selected_tools\": [\"tool_name\", ...],",
" \"selected_skills\": [\"skill_name\", ...],",
" \"reason\": \"简短路由理由\"",
"}",
"规则:",
"1) 优先原子工具能力。若可通过工具链路完成need_skills=false。",
"2) 只有当工具能力不足以覆盖业务约束时need_skills=true 并选择少量最相关技能。",
"3) selected_skills 仅填写技能名称(来自技能摘要)。",
"4) selected_tools 仅填写可用工具名。",
"5) 不要输出 JSON 之外内容。",
}, "\n")
}
func (o *Orchestrator) buildRouteUserPrompt(userInput string) string {
return strings.Join([]string{
"当前运行环境:",
formatRuntimeContextForPrompt(),
"",
"用户问题:",
userInput,
"",
"可用工具摘要:",
o.formatToolDoc(),
"",
"可用技能摘要:",
o.formatSkillSummariesForPrompt(),
"",
"请给出路由 JSON。",
}, "\n")
}
func (o *Orchestrator) normalizeToolSelection(in []string) []string {
if len(in) == 0 {
return nil
}
allowed := map[string]struct{}{}
for _, t := range o.tools.List() {
allowed[strings.ToLower(strings.TrimSpace(t.Name()))] = struct{}{}
}
out := make([]string, 0, len(in))
set := map[string]struct{}{}
for _, name := range in {
n := strings.ToLower(strings.TrimSpace(name))
if n == "" {
continue
}
if _, ok := allowed[n]; !ok {
continue
}
if _, exists := set[n]; exists {
continue
}
set[n] = struct{}{}
out = append(out, n)
}
sort.Strings(out)
return out
}
func (o *Orchestrator) resolveSkillsByNames(names []string, maxCount int) []knowledge.Skill {
if len(names) == 0 {
return nil
}
if maxCount <= 0 {
maxCount = 4
}
all := o.getSkillsSnapshot()
idx := make(map[string]knowledge.Skill, len(all))
for _, sk := range all {
key := strings.ToLower(strings.TrimSpace(sk.Name))
if key != "" {
idx[key] = sk
}
}
out := make([]knowledge.Skill, 0, maxCount)
used := map[string]struct{}{}
for _, name := range names {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
continue
}
sk, ok := idx[key]
if !ok {
continue
}
if _, exists := used[key]; exists {
continue
}
used[key] = struct{}{}
out = append(out, sk)
if len(out) >= maxCount {
break
}
}
return out
}
func formatRouteForPrompt(route capabilityRoutingResult) string {
b := strings.Builder{}
if route.UsedFallback {
b.WriteString("router_status: fallback\n")
} else {
b.WriteString("router_status: ok\n")
}
b.WriteString("need_skills: ")
b.WriteString(strconv.FormatBool(route.NeedSkills))
b.WriteString("\n")
b.WriteString("selected_tools: ")
if len(route.SelectedToolNames) == 0 {
b.WriteString("(none)")
} else {
b.WriteString(strings.Join(route.SelectedToolNames, ", "))
}
b.WriteString("\n")
b.WriteString("selected_skill_count: ")
b.WriteString(strconv.Itoa(len(route.SelectedSkills)))
b.WriteString("\n")
if strings.TrimSpace(route.Reason) != "" {
b.WriteString("reason: ")
b.WriteString(strings.TrimSpace(route.Reason))
}
return strings.TrimSpace(b.String())
}
func (o *Orchestrator) selectRelevantSkills(userInput string, maxCount int) []knowledge.Skill {
if maxCount <= 0 {
maxCount = 4
}
query := strings.TrimSpace(strings.ToLower(userInput))
all := o.getSkillsSnapshot()
if query == "" || len(all) <= maxCount {
return all
}
queryTokens := buildQueryTokens(query)
type item struct {
skill knowledge.Skill
score int
}
ranked := make([]item, 0, len(all))
for _, sk := range all {
hay := strings.ToLower(sk.Name + "\n" + clipForScoring(sk.Content, 1800))
score := 0
if strings.Contains(hay, query) {
score += 8
}
for _, tk := range queryTokens {
if strings.Contains(hay, tk) {
score++
}
}
if score == 0 {
continue
}
ranked = append(ranked, item{skill: sk, score: score})
}
if len(ranked) == 0 {
return nil
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].score == ranked[j].score {
return strings.ToLower(strings.TrimSpace(ranked[i].skill.Name)) < strings.ToLower(strings.TrimSpace(ranked[j].skill.Name))
}
return ranked[i].score > ranked[j].score
})
if len(ranked) > maxCount {
ranked = ranked[:maxCount]
}
out := make([]knowledge.Skill, 0, len(ranked))
for _, r := range ranked {
out = append(out, r.skill)
}
return out
}
func buildQueryTokens(query string) []string {
set := map[string]struct{}{}
collectToken := func(t string) {
t = strings.TrimSpace(t)
if len([]rune(t)) < 2 {
return
}
set[t] = struct{}{}
}
for _, part := range strings.FieldsFunc(query, func(r rune) bool {
if r >= 'a' && r <= 'z' {
return false
}
if r >= '0' && r <= '9' {
return false
}
if r >= 0x4e00 && r <= 0x9fff {
return false
}
return true
}) {
collectToken(part)
}
// 针对中文无空格输入,补充 2-gram 提升匹配命中率。
runes := []rune(query)
for i := 0; i+1 < len(runes); i++ {
r1 := runes[i]
r2 := runes[i+1]
if (r1 >= 0x4e00 && r1 <= 0x9fff) && (r2 >= 0x4e00 && r2 <= 0x9fff) {
collectToken(string([]rune{r1, r2}))
}
}
out := make([]string, 0, len(set))
for tk := range set {
out = append(out, tk)
}
sort.Strings(out)
return out
}
func clipForScoring(s string, maxRunes int) string {
if maxRunes <= 0 {
maxRunes = 1800
}
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes])
}
func formatRuntimeContextForPrompt() string {
goos := strings.TrimSpace(strings.ToLower(runtime.GOOS))
if goos == "" {
goos = "unknown"
}
return "当前运行系统 GOOS=" + goos + "。请优先使用与该系统一致的策略。仅当用户明确要求时,才采用其他系统(如 Windows的专用流程。"
}
// emitCapabilityGap 处理能力缺口信息埋点或者通过 AI 自动创建生成相应缺失技能的逻辑
func (o *Orchestrator) emitCapabilityGap(chatID, userID, intent, reason string) {
if !o.enableCapabilityGap {
return
}
intent = strings.TrimSpace(intent)
reason = strings.TrimSpace(reason)
if intent == "" || reason == "" {
return
}
if len(intent) > 1000 {
intent = intent[:1000] // 防止恶意使用超长 payload
}
if len(reason) > 240 {
reason = reason[:240] // 保证状态长度在 DB 内正常可用
}
if err := o.store.SaveCapabilityGap(chatID, userID, intent, reason); err != nil && o.log != nil {
o.log.Warnf("save capability gap failed chat_id=%s user_id=%s err=%v", chatID, userID, err)
return
}
// 提取出高频率缺口并在超出阈值后进行 draft 生成
clusters, err := o.store.TopCapabilityGapClusters(20, time.Now().UTC().Add(-o.gapLookbackDuration))
if err != nil {
if o.log != nil {
o.log.Warnf("query capability gap clusters failed err=%v", err)
}
return
}
for _, c := range clusters {
if c.Count < o.gapDraftTriggerCount {
continue
}
path, created, draftErr := knowledge.GenerateSkillDraft(c, o.autoSkillDir)
if draftErr != nil {
if o.log != nil {
o.log.Warnf("generate skill draft failed intent_key=%s reason=%s err=%v", c.IntentKey, c.Reason, draftErr)
}
continue
}
if created && o.log != nil {
o.log.Infof("capability gap draft generated path=%s intent_key=%s reason=%s count=%d", path, c.IntentKey, c.Reason, c.Count)
}
// 如果生成了新技能则将它们重新加载进环境
if created {
if reloadErr := o.ReloadSkills(); reloadErr != nil && o.log != nil {
o.log.Warnf("auto reload skills failed after generation path=%s err=%v", path, reloadErr)
}
}
}
}
// ReloadSkills 会从提供的技能目录动态从最新存储位置载入所有技能定义而不重启系统。
func (o *Orchestrator) ReloadSkills() error {
skills, err := knowledge.LoadSkillSet(o.skillsDir)
if err != nil {
return err
}
summaries, err := knowledge.LoadSkillSummaries(o.skillsDir)
if err != nil {
return err
}
// 利用 RWMutex 做热更新保护
o.skillsMu.Lock()
o.skills = skills
o.skillSummaries = copySkillSummaries(summaries)
o.skillsMu.Unlock()
if o.log != nil {
o.log.Infof("skills hot reloaded count=%d dir=%s", len(skills), o.skillsDir)
}
return nil
}
func (o *Orchestrator) getSkillsSnapshot() []knowledge.Skill {
o.skillsMu.RLock()
defer o.skillsMu.RUnlock()
out := make([]knowledge.Skill, len(o.skills))
copy(out, o.skills)
return out
}
func (o *Orchestrator) getSkillSummariesSnapshot() []knowledge.SkillSummary {
o.skillsMu.RLock()
defer o.skillsMu.RUnlock()
return copySkillSummaries(o.skillSummaries)
}
// BuildCapabilityGapReport 生成指定数量以内的近期高频缺失功能报错并格式化成报表。
func (o *Orchestrator) BuildCapabilityGapReport(limit int) (string, error) {
clusters, err := o.store.TopCapabilityGapClusters(limit, time.Now().UTC().Add(-o.gapLookbackDuration))
if err != nil {
return "", err
}
if len(clusters) == 0 {
return "最近没有采集到能力缺口记录。", nil
}
b := strings.Builder{}
b.WriteString("高频能力缺口清单:\n")
for i, c := range clusters {
line := fmt.Sprintf("%d) intent=%s | reason=%s | count=%d | last_seen=%s\n", i+1, c.IntentKey, c.Reason, c.Count, c.LastSeenAt.Format("2006-01-02 15:04:05"))
b.WriteString(line)
}
b.WriteString("\n草稿目录")
b.WriteString(o.autoSkillDir)
b.WriteString("\n系统会在达到阈值后自动生成并热加载技能你也可以手动发送 /reload_skills。")
return b.String(), nil
}
func (o *Orchestrator) formatSkillSummariesForPrompt() string {
summaries := o.getSkillSummariesSnapshot()
if len(summaries) == 0 {
return "(none)"
}
sort.Slice(summaries, func(i, j int) bool {
left := strings.ToLower(strings.TrimSpace(summaries[i].DirName))
right := strings.ToLower(strings.TrimSpace(summaries[j].DirName))
if left == right {
return strings.ToLower(strings.TrimSpace(summaries[i].Name)) < strings.ToLower(strings.TrimSpace(summaries[j].Name))
}
return left < right
})
b := strings.Builder{}
for _, summary := range summaries {
dir := strings.TrimSpace(summary.DirName)
name := strings.TrimSpace(summary.Name)
desc := strings.TrimSpace(summary.Description)
if name == "" {
continue
}
if len(desc) > 220 {
desc = desc[:220]
}
b.WriteString("- ")
if dir != "" {
b.WriteString("[")
b.WriteString(dir)
b.WriteString("] ")
}
b.WriteString(name)
if desc != "" {
b.WriteString(" => ")
b.WriteString(desc)
}
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}
func copySkillSummaries(in []knowledge.SkillSummary) []knowledge.SkillSummary {
out := make([]knowledge.SkillSummary, len(in))
copy(out, in)
for i := range out {
out[i].DirName = strings.TrimSpace(out[i].DirName)
out[i].Name = strings.TrimSpace(out[i].Name)
out[i].Description = strings.TrimSpace(out[i].Description)
}
return out
}
func formatToolErrorObservation(code, action, reason string) string {
code = strings.TrimSpace(code)
action = strings.TrimSpace(action)
reason = strings.TrimSpace(reason)
if code == "" {
code = "TOOL_EXEC_ERROR"
}
if action == "" {
action = "unknown"
}
if reason == "" {
reason = "unknown error"
}
return "ERROR_CODE=" + code + "; TOOL=" + action + "; REASON=" + reason
}
func formatSkills(skills []knowledge.Skill) string {
b := strings.Builder{}
for _, skill := range skills {
b.WriteString("## ")
b.WriteString(skill.Name)
b.WriteString("\n")
b.WriteString(skill.Content)
b.WriteString("\n\n")
}
return strings.TrimSpace(b.String())
}
func (o *Orchestrator) formatToolDoc() string {
list := o.tools.List()
if len(list) == 0 {
return "(none)"
}
sort.Slice(list, func(i, j int) bool {
return list[i].Name() < list[j].Name()
})
b := strings.Builder{}
for _, t := range list {
b.WriteString("- ")
b.WriteString(t.Name())
b.WriteString(": ")
b.WriteString(t.Description())
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}