Migrate LLM client to OpenAI SDK and implement WebUI-specific fileID handling

This commit is contained in:
2026-03-10 17:54:50 +08:00
parent 49f6297631
commit 0e1a800646
23 changed files with 1162 additions and 8201 deletions

View File

@@ -2,6 +2,7 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"runtime"
"sort"
@@ -43,14 +44,6 @@ type pendingFileRef struct {
MimeType string
}
type capabilityRoutingResult struct {
NeedSkills bool
SelectedToolNames []string
SelectedSkills []knowledge.Skill
Reason string
UsedFallback bool
}
type filePromptContext struct {
Summary string
FatalReason string
@@ -110,11 +103,25 @@ func NewOrchestrator(
// - 是否需要调用工具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)
return o.handleMessageInternal(ctx, chatID, userID, text, nil, false)
}
func (o *Orchestrator) HandleMessageWithFiles(ctx context.Context, chatID, userID, text string, files []llm.InputFile) (string, error) {
return o.handleMessageInternal(ctx, chatID, userID, text, files)
return o.handleMessageInternal(ctx, chatID, userID, text, files, false)
}
// HandleMessageWithFileIDs 接收用户文本与外部 file_id 列表,复用统一 ReAct 链路。
// 该方法会先把 file_id 注入当前会话上下文,然后调用常规 HandleMessage 流程。
func (o *Orchestrator) HandleMessageWithFileIDs(ctx context.Context, chatID, userID, text string, fileIDs []string) (string, error) {
ids := nonEmptyIDs(fileIDs)
if len(ids) > 0 {
refs := make([]pendingFileRef, 0, len(ids))
for _, id := range ids {
refs = append(refs, pendingFileRef{ID: id})
}
o.appendPendingFiles(chatID, userID, refs)
}
return o.handleMessageInternal(ctx, chatID, userID, text, nil, true)
}
// UploadAndCacheFiles 上传文件到 LLM 并缓存 file_id供后续同会话文本问答复用。
@@ -135,7 +142,7 @@ func (o *Orchestrator) UploadAndCacheFiles(ctx context.Context, chatID, userID s
return ids, nil
}
func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID, text string, files []llm.InputFile) (string, error) {
func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID, text string, files []llm.InputFile, appendFileIDText bool) (string, error) {
// 为链路追踪设置唯一的 TraceID
traceID := logger.NewTraceID()
ctx = logger.WithTraceID(ctx, traceID)
@@ -228,9 +235,7 @@ func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID
}
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)
response, err := o.runUnifiedReAct(ctx, chatID, userID, compressed, text, fileCtx, appendFileIDText)
if err != nil {
if o.log != nil {
o.log.Errorf("%s message generation failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
@@ -256,128 +261,198 @@ func (o *Orchestrator) handleMessageInternal(ctx context.Context, chatID, userID
}
// buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。
// 工具始终可用;技能仅按当前问题挑选相关项作为增强上下文
func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, route capabilityRoutingResult) string {
// 工具定义通过 API 的 tools 字段传递;此处只需包含人格、技能、运行环境和思考指引
func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string) string {
skillMetaDoc := o.formatSkillSummariesForPrompt()
relevantSkillsDoc := o.formatSelectedSkillsForPrompt(userInput, route.SelectedSkills)
toolDoc := o.formatToolDoc()
relevantSkillsDoc := o.formatSelectedSkillsForPrompt(userInput, nil)
runtimeDoc := formatRuntimeContextForPrompt()
routeDoc := formatRouteForPrompt(route)
return strings.Join([]string{
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
o.soul,
"",
"===== ReAct 思考指引 =====",
"你采用 ReActReasoning + Acting模式进行任务处理。",
"1. 思考优先在做出任何行动之前先在回复中阐述你的推理过程Thought。",
"2. 工具调用如果需要获取信息或执行操作使用提供的工具函数function calling进行调用。",
"3. 观察反馈:检查工具返回的结果,据此决定下一步行动。",
"4. 最终回答:当你有足够信息时,直接给出面向用户的最终文本回复,不要调用工具。",
"",
"注意事项:",
"- 每次要么调用工具,要么给出最终回答,不要两者都做。",
"- 如果工具调用失败根据错误信息Traceback调整策略后重试或给出替代方案。",
"- 涉及文件、目录、命令时,优先调用工具获取真实结果,不要猜测。",
"- 你的思考过程Thought应写在回复内容中帮助追踪推理逻辑。",
"",
"===== 运行环境 =====",
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) {
// runUnifiedReAct 执行统一的 ReAct 循环,使用原生 function calling API
// messages 数组随交互动态增长system → history → user → assistant(tool_calls) → tool → ...
// 循环持续到 LLM 返回无 tool_calls 的纯文本回复(即最终回答)或达到安全上限。
func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, compressedContext, userInput string, fileCtx filePromptContext, appendFileIDText bool) (string, error) {
traceID := logger.TraceIDFromContext(ctx)
traceLogPrefix := "trace_id=" + traceID
if strings.TrimSpace(routeInput) == "" {
routeInput = composeRouteInput(userInput, fileCtx.Summary)
}
systemPrompt := o.buildUnifiedSystemPrompt(routeInput, route)
systemPrompt := o.buildUnifiedSystemPrompt(userInput)
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)
o.log.Infof("%s unified react start", traceLogPrefix)
}
// 安全上限:防止无限循环(当前暂不使用 reactMaxStep 配置约束,使用固定硬上限)
// 检查 LLM 客户端是否支持原生 tool_calls
toolCallClient, supportsToolCalls := o.llm.(llm.ToolCallChatClient)
if !supportsToolCalls {
if o.log != nil {
o.log.Warnf("%s llm client does not support ToolCallChatClient, falling back to legacy ReAct", traceLogPrefix)
}
return o.runLegacyReAct(ctx, chatID, userID, compressedContext, userInput, fileCtx, appendFileIDText)
}
// 构建初始 messages 数组
messages := make([]llm.PromptMessage, 0, 32)
messages = append(messages, llm.PromptMessage{Role: "system", Content: systemPrompt})
// 加入历史会话上下文
//messages = append(messages, parseCompressedHistoryMessages(compressedContext)...)
// 加入当前用户消息
messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput})
// 构建工具定义列表(通过 API tools 字段传递)
toolDefs := o.buildToolDefinitions()
const maxSteps = 20
for step := 1; step <= maxSteps; step++ {
if o.log != nil {
o.log.Infof("%s react step=%d start messages_count=%d", traceLogPrefix, step, len(messages))
}
// 调用 LLM传入完整 messages + tools 定义)
completion, err := toolCallClient.GenerateWithTools(ctx, messages, toolDefs, fileCtx.FileIDs, appendFileIDText)
if err != nil {
return "", err
}
if o.log != nil {
o.log.Infof("%s react step=%d content_len=%d tool_calls=%d",
traceLogPrefix, step, len(completion.Content), len(completion.ToolCalls))
if completion.Content != "" {
o.log.Debugf("%s react step=%d thought=%q", traceLogPrefix, step, completion.Content)
}
}
// ========== 无 tool_calls → 最终回答 ==========
if len(completion.ToolCalls) == 0 {
finalText := strings.TrimSpace(completion.Content)
if finalText == "" {
finalText = "已完成处理。"
}
if o.log != nil {
o.log.Infof("%s react final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText))
}
return finalText, nil
}
// ========== 有 tool_calls → 将 assistant 消息加入历史,然后执行工具 ==========
assistantMsg := llm.PromptMessage{
Role: "assistant",
Content: completion.Content,
ToolCalls: completion.ToolCalls,
}
messages = append(messages, assistantMsg)
// 逐个执行工具调用,并将结果作为 tool 角色消息加入
for _, tc := range completion.ToolCalls {
toolName := strings.ToLower(strings.TrimSpace(tc.Function.Name))
toolInput := extractToolInput(tc.Function.Arguments)
tool, ok := o.tools.Get(toolName)
if !ok {
if o.log != nil {
o.log.Warnf("%s react step=%d tool_not_found=%s", traceLogPrefix, step, toolName)
}
messages = append(messages, llm.PromptMessage{
Role: "tool",
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: formatToolErrorObservation("TOOL_NOT_FOUND", toolName, "该工具不存在,请检查工具名称后重试"),
})
o.emitCapabilityGap(chatID, userID, userInput, "tool_not_found:"+toolName)
continue
}
if o.log != nil {
o.log.Infof("%s react step=%d tool_call tool=%s input=%q", traceLogPrefix, step, toolName, toolInput)
}
toolOut, toolErr := tool.Call(ctx, toolInput)
obs := strings.TrimSpace(toolOut)
if obs == "" {
obs = "(empty output)"
}
if toolErr != nil {
obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs
o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName)
}
// 限制观察值长度防止超出 LLM 上下文窗口
if len(obs) > 4000 {
obs = obs[:4000] + "\n...(truncated)"
}
if o.log != nil {
o.log.Infof("%s react step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs))
}
messages = append(messages, llm.PromptMessage{
Role: "tool",
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: obs,
})
}
}
// 达到安全上限仍未得到最终回答
o.emitCapabilityGap(chatID, userID, userInput, "react_step_exhausted")
return "我尝试了多轮推理与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
}
// runLegacyReAct 是旧版基于 JSON 决策解析的 ReAct 循环,作为不支持 tool_calls 的 LLM 的降级方案。
func (o *Orchestrator) runLegacyReAct(ctx context.Context, chatID, userID, compressedContext, userInput string, fileCtx filePromptContext, appendFileIDText bool) (string, error) {
traceID := logger.TraceIDFromContext(ctx)
traceLogPrefix := "trace_id=" + traceID
systemPrompt := o.buildLegacySystemPrompt(userInput)
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)
o.log.Infof("%s legacy react step=%d start", traceLogPrefix, step)
}
// 构造本轮 user prompt历史上下文 + 用户问题 + 推理记录
prompt := strings.Join([]string{
"历史上下文:",
compressedContext,
"",
"用户问题:",
userInput,
"",
"文件上下文:",
defaultIfEmpty(fileCtx.Summary, "(none)"),
"",
"当前推理记录(按时间顺序):",
scratchpad,
"",
"请输出你的 JSON 决策。",
}, "\n")
raw, err := o.generateWithOptionalFiles(ctx, systemPrompt, prompt, fileCtx.FileIDs)
messages := buildReActMessages(systemPrompt, compressedContext, userInput, fileCtx.Summary, scratchpad)
raw, err := o.generateWithOptionalFilesMessages(ctx, messages, fileCtx.FileIDs, appendFileIDText)
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 {
@@ -389,40 +464,26 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
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"
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"
scratchpad += "Step " + strconv.Itoa(step) + " Observation: " + formatToolErrorObservation("TOOL_NOT_FOUND", action, "该工具不存在") + "\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 == "" {
@@ -432,37 +493,95 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
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
// buildLegacySystemPrompt 为不支持 tool_calls 的旧版 ReAct 链路构建 system prompt含 JSON 输出格式约束)。
func (o *Orchestrator) buildLegacySystemPrompt(userInput string) string {
skillMetaDoc := o.formatSkillSummariesForPrompt()
relevantSkillsDoc := o.formatSelectedSkillsForPrompt(userInput, nil)
toolDoc := o.formatToolDoc()
runtimeDoc := formatRuntimeContextForPrompt()
return strings.Join([]string{
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
o.soul,
"",
"===== 运行环境 =====",
runtimeDoc,
"",
"===== 可用技能概览 =====",
skillMetaDoc,
"",
"===== 本轮相关技能 =====",
relevantSkillsDoc,
"",
"===== 可用工具 =====",
toolDoc,
"",
"===== 输出格式约束 =====",
"你必须使用 ReAct 模式进行决策。每次回复必须是且仅是一个 JSON 对象:",
"{",
" \"thought\": \"你的推理过程(必填)\",",
" \"action\": \"要调用的工具名称(不调工具时填 none\",",
" \"action_input\": \"传给工具的输入\",",
" \"is_final_answer\": true 或 false,",
" \"final_answer\": \"当 is_final_answer=true 时填写给用户的最终回复\"",
"}",
}, "\n")
}
// buildToolDefinitions 将工具注册表转换为 OpenAI function calling 所需的 ToolDefinition 列表。
func (o *Orchestrator) buildToolDefinitions() []llm.ToolDefinition {
list := o.tools.List()
defs := make([]llm.ToolDefinition, 0, len(list))
defaultParams := json.RawMessage(`{"type":"object","properties":{"input":{"type":"string","description":"工具的输入命令或查询内容"}},"required":["input"]}`)
sort.Slice(list, func(i, j int) bool {
return list[i].Name() < list[j].Name()
})
for _, t := range list {
defs = append(defs, llm.ToolDefinition{
Type: "function",
Function: llm.ToolFunctionDef{
Name: t.Name(),
Description: t.Description(),
Parameters: defaultParams,
},
})
}
if fileSummary == "" {
return userInput
return defs
}
// extractToolInput 从 LLM 的 function calling arguments JSON 中提取工具输入字符串。
func extractToolInput(arguments string) string {
arguments = strings.TrimSpace(arguments)
if arguments == "" {
return ""
}
return userInput + "\n\n" + fileSummary
var args struct {
Input string `json:"input"`
}
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
// 降级:直接将 arguments 作为输入
return arguments
}
if args.Input != "" {
return args.Input
}
return arguments
}
func (o *Orchestrator) prepareFilePromptContext(ctx context.Context, files []llm.InputFile, pending []pendingFileRef) filePromptContext {
@@ -535,16 +654,85 @@ func buildFileSummary(pending, uploaded []pendingFileRef) string {
return strings.Join(lines, "\n")
}
func (o *Orchestrator) generateWithOptionalFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error) {
func (o *Orchestrator) generateWithOptionalFilesMessages(ctx context.Context, messages []llm.PromptMessage, fileIDs []string, appendFileIDText bool) (string, error) {
ids := nonEmptyIDs(fileIDs)
if len(ids) == 0 {
if client, ok := o.llm.(llm.MessageChatClient); ok {
return client.GenerateMessages(ctx, messages)
}
systemPrompt, userPrompt := fallbackPromptsFromMessages(messages)
return o.llm.Generate(ctx, systemPrompt, userPrompt)
}
if client, ok := o.llm.(llm.FileMessageChatClient); ok {
return client.GenerateMessagesWithFiles(ctx, messages, ids, appendFileIDText)
}
client, ok := o.llm.(llm.FileChatClient)
if !ok {
systemPrompt, userPrompt := fallbackPromptsFromMessages(messages)
return o.llm.Generate(ctx, systemPrompt, userPrompt)
}
return client.GenerateWithFiles(ctx, systemPrompt, userPrompt, ids)
systemPrompt, userPrompt := fallbackPromptsFromMessages(messages)
return client.GenerateWithFiles(ctx, systemPrompt, userPrompt, ids, appendFileIDText)
}
func buildReActMessages(systemPrompt, compressedContext, userInput, fileSummary, scratchpad string) []llm.PromptMessage {
msgs := make([]llm.PromptMessage, 0, 16)
msgs = append(msgs, llm.PromptMessage{Role: "system", Content: systemPrompt})
msgs = append(msgs, parseCompressedHistoryMessages(compressedContext)...)
if strings.TrimSpace(fileSummary) != "" {
msgs = append(msgs, llm.PromptMessage{Role: "assistant", Content: "文件上下文摘要:\n" + strings.TrimSpace(fileSummary)})
}
if strings.TrimSpace(scratchpad) != "" {
msgs = append(msgs, llm.PromptMessage{Role: "assistant", Content: "推理记录:\n" + strings.TrimSpace(scratchpad)})
}
msgs = append(msgs, llm.PromptMessage{Role: "user", Content: userInput})
return msgs
}
func parseCompressedHistoryMessages(compressed string) []llm.PromptMessage {
compressed = strings.TrimSpace(compressed)
if compressed == "" {
return nil
}
lines := strings.Split(compressed, "\n")
out := make([]llm.PromptMessage, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
idx := strings.Index(line, ":")
if idx <= 0 {
out = append(out, llm.PromptMessage{Role: "assistant", Content: line})
continue
}
role := strings.ToLower(strings.TrimSpace(line[:idx]))
content := strings.TrimSpace(line[idx+1:])
if role != "system" && role != "user" && role != "assistant" {
role = "assistant"
}
out = append(out, llm.PromptMessage{Role: role, Content: content})
}
return out
}
func fallbackPromptsFromMessages(messages []llm.PromptMessage) (string, string) {
sysParts := make([]string, 0, 2)
userParts := make([]string, 0, len(messages))
for _, m := range messages {
role := strings.ToLower(strings.TrimSpace(m.Role))
content := strings.TrimSpace(m.Content)
if content == "" {
continue
}
if role == "system" {
sysParts = append(sysParts, content)
continue
}
userParts = append(userParts, role+": "+content)
}
return strings.Join(sysParts, "\n\n"), strings.Join(userParts, "\n")
}
func (o *Orchestrator) buildFileUploadAck(ctx filePromptContext) string {
@@ -670,180 +858,6 @@ func (o *Orchestrator) formatSelectedSkillsForPrompt(userInput string, selected
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