package engine // 工具结果摘要生成器. // // 工具执行后生成一行简短摘要(5-20 字符),用于 UI 显示. // 例如:Bash 工具执行 "npm install" 后,摘要为 "安装依赖完成". // // 设计思路: // - 如果输出很短(<100 字符),直接用输出本身做摘要 // - 否则调用 fast 模型(通过参数传入模型 ID)生成摘要 // - 摘要生成是异步的,不阻塞主流程 // - 失败时返回工具名作为 fallback(不能因为摘要失败影响主流程) // - 不硬编码模型 ID,通过构造参数传入 import ( "context" "fmt" "strings" "sync" "time" "git.flytoex.net/yuanwei/flyto-agent/internal/transport/retry" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // shortOutputThreshold 是「短输出直接做摘要」的长度阈值. const shortOutputThreshold = 100 // maxSummaryLength 是摘要最大长度. const maxSummaryLength = 20 // ToolSummaryGenerator 生成工具执行结果的简短摘要. // // 不硬编码模型 ID -- 通过构造参数传入 fast 模型 ID(如从 ModelRegistry 的 RoleFast 获取). type ToolSummaryGenerator struct { provider flyto.ModelProvider modelID string // fast 模型 ID(从 ModelRegistry 获取) mu sync.Mutex } // NewToolSummaryGenerator 创建一个工具摘要生成器. // // provider 是模型提供商(flyto.ModelProvider 接口). // modelID 是用于生成摘要的 fast 模型 ID(如 "claude-haiku-..."). // 由调用方从 ModelRegistry 的 RoleFast 获取,不在此处硬编码. func NewToolSummaryGenerator(provider flyto.ModelProvider, modelID string) *ToolSummaryGenerator { return &ToolSummaryGenerator{ provider: provider, modelID: modelID, } } // GenerateSummary 生成工具执行结果的摘要. // // 如果输出很短(<100 字符),直接截取作为摘要. // 否则调用 fast 模型生成摘要. // 失败时返回 toolName 作为 fallback. func (g *ToolSummaryGenerator) GenerateSummary(ctx context.Context, toolName string, input string, output string) string { // 短输出直接做摘要 if len(output) < shortOutputThreshold { return g.truncateSummary(cleanOutput(output)) } // 调用 fast 模型生成摘要 summary, err := g.callFastModel(ctx, toolName, input, output) if err != nil { // 失败时返回工具名作为 fallback return toolName } return g.truncateSummary(summary) } // GenerateSummaryAsync 异步生成摘要,通过 channel 返回结果. // 不阻塞主流程. // // 升华改进(ELEVATED): 使用独立 context 而非直接复用外部 ctx-- // 外部 ctx 通常绑定到"一次工具调用"的生命周期,工具返回后 ctx 立即取消; // 如果 AI provider 不支持流式取消,goroutine 会在 provider.Stream 上永久阻塞. // 独立 ctx 配合 30s 超时确保 goroutine 有明确的退出路径. // 替代方案:<直接复用外部 ctx> - // 否决:ctx 取消 → provider.Stream 阻塞不退出 → goroutine 泄漏. func (g *ToolSummaryGenerator) GenerateSummaryAsync(ctx context.Context, toolName, toolID, input, output string, ch chan<- Event) { // 使用独立 ctx 以防止 goroutine 随调用方 ctx 取消而泄漏 // 上限 30s:摘要生成的合理超时,超时后退化为工具名 summaryCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) go func() { defer cancel() summary := g.GenerateSummary(summaryCtx, toolName, input, output) select { case ch <- &ToolSummaryEvent{ ID: toolID, ToolName: toolName, Summary: summary, }: case <-ctx.Done(): // 外部 ctx 已取消(调用方不再等待结果),丢弃摘要 } }() } // callFastModel 调用 fast 模型生成摘要. // 使用流式 API 收集完整响应文本. func (g *ToolSummaryGenerator) callFastModel(ctx context.Context, toolName string, input string, output string) (string, error) { // 如果没有配置模型 ID 或 provider,直接返回工具名 if g.modelID == "" || g.provider == nil { return toolName, nil } // 截断输出,避免发送过长内容 truncatedOutput := output if len(truncatedOutput) > 500 { truncatedOutput = truncatedOutput[:500] + "..." } truncatedInput := input if len(truncatedInput) > 200 { truncatedInput = truncatedInput[:200] + "..." } prompt := "请为以下工具执行结果生成一行简短摘要(5-20个字符),仅返回摘要文本,不要任何解释:\n" + "工具:" + toolName + "\n" + "输入:" + truncatedInput + "\n" + "输出:" + truncatedOutput req := &flyto.Request{ Model: g.modelID, MaxTokens: 50, Messages: []flyto.Message{ {Role: flyto.RoleUser, Blocks: []flyto.Block{flyto.TextBlock(prompt)}}, }, } // Tool-summary is a best-effort background enrichment; failures here // shouldn't look like main-thread failures in the error stream. // // tool-summary 是尽力而为的后台增强; 失败不应在错误流里看起来像 // 主线程失败. ctx = retry.WithQuerySource(ctx, SourceSummary.String()) streamCh, err := g.provider.Stream(ctx, req) if err != nil { return "", err } // 从流式响应中收集文本 var resultText strings.Builder for evt := range streamCh { switch e := evt.(type) { case *flyto.TextDeltaEvent: if e.Text != "" { resultText.WriteString(e.Text) } case *flyto.ErrorEvent: return "", fmt.Errorf("stream error: %w", e.Err) } } text := strings.TrimSpace(resultText.String()) if text != "" { return text, nil } return toolName, nil } // truncateSummary 将摘要截断到最大长度. func (g *ToolSummaryGenerator) truncateSummary(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } // 精妙之处(CLEVER): 按 rune 而非 byte 截断--中文摘要每个字符 3 字节, // 按 byte 截断会切断多字节字符导致乱码.用 []rune 转换保证在字符边界截断. runes := []rune(s) if len(runes) > maxSummaryLength { return string(runes[:maxSummaryLength]) } return s } // cleanOutput 清理输出文本,去掉多余的空白和换行. func cleanOutput(s string) string { s = strings.TrimSpace(s) // 只保留第一行 if idx := strings.IndexByte(s, '\n'); idx >= 0 { s = s[:idx] } return s }