fix: surface summarization model errors

This commit is contained in:
temp
2026-08-25 18:28:47 +08:00
parent 4c011abb9d
commit d7581a6373
15 changed files with 701 additions and 28 deletions
+1 -1
View File
@@ -747,7 +747,7 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
if shouldPersistEinoAgentTraceAfterRunError(ctx) {
h.persistEinoAgentTraceForResume(conversationID, resultMA)
}
errMsg := "执行失败: " + errMA.Error()
errMsg := "执行失败: " + multiagent.EinoClientRunErrorMessage(errMA)
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
+3 -2
View File
@@ -391,7 +391,8 @@ func (h *AgentHandler) handleBatchSubTaskRunError(
}
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
errorMsg := "执行失败: " + runErr.Error()
clientErr := multiagent.EinoClientRunErrorMessage(runErr)
errorMsg := "执行失败: " + clientErr
if assistantMessageID != "" {
if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
@@ -404,5 +405,5 @@ func (h *AgentHandler) handleBatchSubTaskRunError(
h.logger.Warn("保存错误详情失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
}
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, BatchTaskStatusFailed, "", runErr.Error())
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, BatchTaskStatusFailed, "", clientErr)
}
+7 -5
View File
@@ -371,15 +371,17 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
h.logger.Error("Eino ADK 单代理执行失败", zap.Error(runErr))
taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "执行失败: " + runErr.Error()
clientErr := multiagent.EinoClientRunErrorMessage(runErr)
errMsg := "执行失败: " + clientErr
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
}
sendEvent("error", errMsg, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
})
errData := multiagent.EinoClientRunErrorFields(runErr)
errData["conversationId"] = conversationID
errData["messageId"] = assistantMessageID
errData["error"] = errMsg
sendEvent("error", errMsg, errData)
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
+12 -7
View File
@@ -385,15 +385,17 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "执行失败: " + runErr.Error()
clientErr := multiagent.EinoClientRunErrorMessage(runErr)
errMsg := "执行失败: " + clientErr
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
}
sendEvent("error", errMsg, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
})
errData := multiagent.EinoClientRunErrorFields(runErr)
errData["conversationId"] = conversationID
errData["messageId"] = assistantMessageID
errData["error"] = errMsg
sendEvent("error", errMsg, errData)
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
@@ -513,11 +515,14 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.Error()
clientErr := multiagent.EinoClientRunErrorMessage(runErr)
errMsg := "执行失败: " + clientErr
if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
errData := multiagent.EinoClientRunErrorFields(runErr)
errData["error"] = errMsg
c.JSON(http.StatusInternalServerError, errData)
return
}
mw := &h.config.MultiAgent.EinoMiddleware
@@ -12,15 +12,24 @@ import (
)
type capturingAgenticChatModel struct {
mu sync.Mutex
inputs [][]*schema.AgenticMessage
output *schema.AgenticMessage
mu sync.Mutex
inputs [][]*schema.AgenticMessage
output *schema.AgenticMessage
outputs []*schema.AgenticMessage
}
func (m *capturingAgenticChatModel) Generate(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
m.mu.Lock()
m.inputs = append(m.inputs, input)
callNo := len(m.inputs)
m.mu.Unlock()
if len(m.outputs) > 0 {
idx := callNo - 1
if idx >= len(m.outputs) {
idx = len(m.outputs) - 1
}
return m.outputs[idx], nil
}
if m.output != nil {
return m.output, nil
}
@@ -111,7 +111,7 @@ func newEinoAgenticSummarizationMiddleware(
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "agentic", &appCfg.OpenAI, logger)
mw, err := summarization.NewTyped[*schema.AgenticMessage](ctx, &summarization.TypedConfig[*schema.AgenticMessage]{
Model: summaryModel,
Model: newNonEmptyAgenticSummaryModel(summaryModel),
ModelOptions: summaryModelOpts,
GenModelInput: func(ctx context.Context, sysInstruction, userInstruction *schema.AgenticMessage, originalMsgs []*schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
classicOriginal := AgenticMessagesToEino(originalMsgs)
@@ -171,6 +171,59 @@ func TestEinoAgenticChatModelAgentCompactsContextBeforeBusinessModel(t *testing.
}
}
func TestEinoAgenticSummarizationMiddlewareRetriesWhenSummaryModelReturnsEmpty(t *testing.T) {
t.Parallel()
ctx := context.Background()
emit := false
summaryModel := &capturingAgenticChatModel{
outputs: []*schema.AgenticMessage{
agenticAssistantTextMessage(""),
agenticAssistantTextMessage("<summary>有效摘要:继续验证 SQL 注入路径</summary>"),
},
}
appCfg := &config.Config{}
appCfg.OpenAI.Model = "gpt-4o"
appCfg.OpenAI.MaxTotalTokens = 5000
appCfg.Database.Path = filepath.Join(t.TempDir(), "cyberstrike.db")
mwCfg := &config.MultiAgentEinoMiddlewareConfig{
SummarizationEmitInternalEvents: &emit,
SummarizationOutputReserveTokens: 1024,
}
mw, err := newEinoAgenticSummarizationMiddleware(ctx, summaryModel, appCfg, mwCfg, "conv-agentic-empty-summary", nil, "", nil)
if err != nil {
t.Fatalf("newEinoAgenticSummarizationMiddleware: %v", err)
}
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
schema.SystemAgenticMessage("system root"),
schema.UserAgenticMessage("授权范围 example.com\n" + strings.Repeat("历史扫描输出 ", 12000)),
agenticAssistantTextMessage("已记录范围"),
schema.UserAgenticMessage("继续验证 SQL 注入路径"),
},
}
_, after, err := mw.BeforeModelRewriteState(ctx, state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState should retry instead of failing on empty summary: %v", err)
}
if after == nil {
t.Fatal("after state is nil")
}
if inputs := summaryModel.snapshotInputs(); len(inputs) < 2 {
t.Fatalf("summary model calls=%d, want retry after empty output", len(inputs))
}
joined := joinClassicMessageContent(AgenticMessagesToEino(after.Messages))
for _, want := range []string{"有效摘要", "继续验证 SQL 注入路径", "原始用户输入与约束账本"} {
if !strings.Contains(joined, want) {
t.Fatalf("retried compacted context missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "本地压缩摘要") {
t.Fatalf("local fallback should not be used:\n%s", joined)
}
}
func TestAppendEinoAgenticChatModelTailMiddlewaresIncludesTypedSummarization(t *testing.T) {
t.Parallel()
mw := newAgenticSystemMessageNormalizerMiddleware(nil, "summary")
+111 -5
View File
@@ -115,11 +115,7 @@ func (h *einoRunErrorHandler) emitError(err error, kind string) {
} else if userErr.retryExhausted {
data["hasModelOriginalError"] = false
}
message := err.Error()
if userErr.message != "" {
message = userErr.message
}
h.progress("error", message, data)
h.progress("error", EinoClientRunErrorMessage(err), data)
}
type einoRunUserError struct {
@@ -131,6 +127,7 @@ type einoRunUserError struct {
retryExhausted bool
totalRetries int
hasModelOriginalError bool
summarizationModelErr bool
}
func einoUserFacingRunError(err error) einoRunUserError {
@@ -152,6 +149,10 @@ func einoUserFacingRunError(err error) einoRunUserError {
return out
}
out.rawLastError = strings.TrimSpace(lastErr.Error())
if raw, ok := einoSummarizationModelRawErrorText(lastErr); ok {
out.rawLastError = raw
out.summarizationModelErr = true
}
if isEinoShouldRetryOutputRejected(lastErr) {
out.kind = "model_output_rejected"
out.summary = "模型未返回原始错误;输出被重试策略拒绝。"
@@ -163,6 +164,9 @@ func einoUserFacingRunError(err error) einoRunUserError {
if strings.TrimSpace(summary) == "" {
summary = einoTrimRetryErrorSummary(lastErr.Error())
}
if out.summarizationModelErr {
summary = einoTrimRetryErrorSummary(out.rawLastError)
}
if kind == "" {
kind = "model_retry_exhausted"
}
@@ -190,3 +194,105 @@ func formatEinoRetryExhaustedMessage(summary string, totalRetries int) string {
}
return "模型调用重试已耗尽:" + summary
}
// EinoClientRunErrorMessage returns the error text that should be shown directly
// to clients. When native retry hides the final provider failure behind a retry
// wrapper, prefer the original last model error so summarization/model issues are
// diagnosable from the frontend without opening server logs.
func EinoClientRunErrorMessage(err error) string {
if err == nil {
return ""
}
userErr := einoUserFacingRunError(err)
if userErr.retryExhausted {
if userErr.hasModelOriginalError && userErr.rawLastError != "" {
if userErr.summarizationModelErr {
return formatEinoSummarizationRetryExhaustedRawModelMessage(userErr.rawLastError, userErr.totalRetries)
}
return formatEinoRetryExhaustedRawModelMessage(userErr.rawLastError, userErr.totalRetries)
}
if userErr.message != "" {
return userErr.message
}
}
if raw, ok := einoSummarizationModelRawErrorText(err); ok {
return formatEinoSummarizationRawModelMessage(raw)
}
return err.Error()
}
func formatEinoRetryExhaustedRawModelMessage(raw string, totalRetries int) string {
raw = strings.TrimSpace(raw)
prefix := "模型调用重试已耗尽"
if totalRetries > 0 {
prefix = fmt.Sprintf("模型调用重试已耗尽(已重试 %d 次)", totalRetries)
}
if raw == "" {
return prefix
}
return prefix + ",最后一次模型原始错误:\n" + raw
}
func formatEinoSummarizationRetryExhaustedRawModelMessage(raw string, totalRetries int) string {
raw = strings.TrimSpace(raw)
prefix := "摘要阶段大模型调用失败,模型调用重试已耗尽"
if totalRetries > 0 {
prefix = fmt.Sprintf("摘要阶段大模型调用失败,模型调用重试已耗尽(已重试 %d 次)", totalRetries)
}
if raw == "" {
return prefix
}
return prefix + ",最后一次大模型报错原文:\n" + raw
}
func formatEinoSummarizationRawModelMessage(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "摘要阶段大模型调用失败。"
}
return "摘要阶段大模型调用失败,大模型报错原文:\n" + raw
}
// EinoClientRunErrorFields returns structured diagnostic fields that handlers can
// attach to their final error event in addition to the visible message.
func EinoClientRunErrorFields(err error) map[string]interface{} {
fields := make(map[string]interface{})
if err == nil {
return fields
}
userErr := einoUserFacingRunError(err)
if userErr.kind != "" {
fields["errorKind"] = userErr.kind
}
if userErr.summary != "" {
fields["errorSummary"] = userErr.summary
}
if userErr.retryExhausted {
fields["retryExhausted"] = true
if userErr.totalRetries > 0 {
fields["totalRetries"] = userErr.totalRetries
}
}
if userErr.rawLastError != "" {
fields["lastError"] = userErr.rawLastError
}
if userErr.technicalError != "" {
fields["technicalError"] = userErr.technicalError
}
if userErr.hasModelOriginalError {
fields["modelOriginalError"] = userErr.rawLastError
} else if userErr.retryExhausted {
fields["hasModelOriginalError"] = false
}
if userErr.summarizationModelErr {
fields["errorPhase"] = "summarization"
fields["summarizationModelError"] = true
fields["modelOriginalError"] = userErr.rawLastError
} else if raw, ok := einoSummarizationModelRawErrorText(err); ok {
fields["errorPhase"] = "summarization"
fields["summarizationModelError"] = true
fields["modelOriginalError"] = raw
fields["lastError"] = raw
}
return fields
}
@@ -155,6 +155,84 @@ func TestEinoRunErrorHandlerRetryExhaustedOriginalErrorProgress(t *testing.T) {
}
}
func TestEinoClientRunErrorMessageUsesRawRetryExhaustedModelError(t *testing.T) {
raw := "summary content is empty: role=assistant content_runes=0 reasoning_runes=42\nprovider request id: req_123"
err := &adk.RetryExhaustedError{
LastErr: errors.New(raw),
TotalRetries: 4,
}
got := EinoClientRunErrorMessage(err)
if !strings.Contains(got, "模型调用重试已耗尽(已重试 4 次)") {
t.Fatalf("message missing retry prefix: %q", got)
}
if !strings.Contains(got, raw) {
t.Fatalf("message should include raw model error:\n%s", got)
}
fields := EinoClientRunErrorFields(err)
if fields["modelOriginalError"] != raw || fields["lastError"] != raw {
t.Fatalf("raw fields = %#v", fields)
}
}
func TestEinoClientRunErrorMessageMarksSummarizationModelRetryError(t *testing.T) {
raw := `POST "https://api.deepseek.com/v1/chat/completions": 429 Too Many Requests: {"error":{"message":"Rate limit reached"}}`
err := &adk.RetryExhaustedError{
LastErr: newEinoSummarizationModelError(errors.New(raw)),
TotalRetries: 4,
}
got := EinoClientRunErrorMessage(err)
for _, want := range []string{
"摘要阶段大模型调用失败",
"模型调用重试已耗尽(已重试 4 次)",
"最后一次大模型报错原文",
raw,
} {
if !strings.Contains(got, want) {
t.Fatalf("message missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "summarization model error") {
t.Fatalf("message leaked internal wrapper:\n%s", got)
}
fields := EinoClientRunErrorFields(err)
if fields["errorPhase"] != "summarization" || fields["summarizationModelError"] != true {
t.Fatalf("phase fields = %#v", fields)
}
if fields["modelOriginalError"] != raw || fields["lastError"] != raw {
t.Fatalf("raw fields = %#v", fields)
}
}
func TestEinoClientRunErrorMessageMarksDirectSummarizationModelError(t *testing.T) {
raw := `POST "https://api.deepseek.com/v1/chat/completions": 400 Bad Request: {"error":{"message":"invalid thinking parameter"}}`
err := newEinoSummarizationModelError(errors.New(raw))
got := EinoClientRunErrorMessage(err)
for _, want := range []string{
"摘要阶段大模型调用失败",
"大模型报错原文",
raw,
} {
if !strings.Contains(got, want) {
t.Fatalf("message missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "summarization model error") {
t.Fatalf("message leaked internal wrapper:\n%s", got)
}
fields := EinoClientRunErrorFields(err)
if fields["errorPhase"] != "summarization" ||
fields["modelOriginalError"] != raw ||
fields["lastError"] != raw {
t.Fatalf("fields = %#v", fields)
}
}
func TestEinoRunErrorHandlerIterationLimitProgress(t *testing.T) {
var events []string
var errorKind interface{}
+1 -1
View File
@@ -167,7 +167,7 @@ func newEinoSummarizationMiddleware(
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "classic", &appCfg.OpenAI, logger)
mw, err := summarization.New(ctx, &summarization.Config{
Model: summaryModel,
Model: newNonEmptySummaryChatModel(summaryModel),
ModelOptions: summaryModelOpts,
GenModelInput: func(ctx context.Context, sysInstruction, userInstruction adk.Message, originalMsgs []adk.Message) ([]adk.Message, error) {
if transcriptPath != "" && len(originalMsgs) > 0 {
@@ -0,0 +1,214 @@
package multiagent
import (
"context"
"errors"
"fmt"
"strings"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
type einoSummarizationModelError struct {
err error
}
func newEinoSummarizationModelError(err error) error {
if err == nil {
return nil
}
var existing *einoSummarizationModelError
if errors.As(err, &existing) {
return err
}
return &einoSummarizationModelError{err: err}
}
func (e *einoSummarizationModelError) Error() string {
if e == nil || e.err == nil {
return "summarization model error"
}
return "summarization model error: " + e.err.Error()
}
func (e *einoSummarizationModelError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func einoSummarizationModelRawErrorText(err error) (string, bool) {
var summaryErr *einoSummarizationModelError
if !errors.As(err, &summaryErr) || summaryErr == nil || summaryErr.err == nil {
return "", false
}
return strings.TrimSpace(summaryErr.err.Error()), true
}
type nonEmptySummaryChatModel struct {
base model.BaseChatModel
}
func newNonEmptySummaryChatModel(base model.BaseChatModel) model.BaseChatModel {
return &nonEmptySummaryChatModel{base: base}
}
func (m *nonEmptySummaryChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
out, err := m.base.Generate(ctx, input, opts...)
if err != nil {
return out, newEinoSummarizationModelError(err)
}
if strings.TrimSpace(classicAssistantTextContent(out)) == "" {
return out, newEinoSummarizationModelError(fmt.Errorf("summary content is empty: %s", classicSummaryEmptyDiagnostics(out)))
}
return out, nil
}
func (m *nonEmptySummaryChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return m.base.Stream(ctx, input, opts...)
}
type nonEmptyAgenticSummaryModel struct {
base model.BaseModel[*schema.AgenticMessage]
}
func newNonEmptyAgenticSummaryModel(base model.BaseModel[*schema.AgenticMessage]) model.BaseModel[*schema.AgenticMessage] {
return &nonEmptyAgenticSummaryModel{base: base}
}
func (m *nonEmptyAgenticSummaryModel) Generate(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
out, err := m.base.Generate(ctx, input, opts...)
if err != nil {
return out, newEinoSummarizationModelError(err)
}
if strings.TrimSpace(agenticAssistantTextContent(out)) == "" {
return out, newEinoSummarizationModelError(fmt.Errorf("summary content is empty: %s", agenticSummaryEmptyDiagnostics(out)))
}
return out, nil
}
func (m *nonEmptyAgenticSummaryModel) Stream(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
return m.base.Stream(ctx, input, opts...)
}
func classicAssistantTextContent(msg *schema.Message) string {
if msg == nil || msg.Role != schema.Assistant {
return ""
}
parts := make([]string, 0, len(msg.AssistantGenMultiContent))
for _, part := range msg.AssistantGenMultiContent {
if part.Type == schema.ChatMessagePartTypeText && part.Text != "" {
parts = append(parts, part.Text)
}
}
if len(parts) > 0 {
return strings.Join(parts, "\n")
}
return msg.Content
}
func agenticAssistantTextContent(msg *schema.AgenticMessage) string {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
return ""
}
parts := make([]string, 0, len(msg.ContentBlocks))
for _, block := range msg.ContentBlocks {
if block != nil && block.AssistantGenText != nil {
parts = append(parts, block.AssistantGenText.Text)
}
}
return strings.Join(parts, "\n")
}
func classicSummaryEmptyDiagnostics(msg *schema.Message) string {
if msg == nil {
return "model returned nil message"
}
var textParts, reasoningParts, otherParts int
var multiTextRunes, multiReasoningRunes int
for _, part := range msg.AssistantGenMultiContent {
switch part.Type {
case schema.ChatMessagePartTypeText:
textParts++
multiTextRunes += len([]rune(strings.TrimSpace(part.Text)))
case schema.ChatMessagePartTypeReasoning:
reasoningParts++
if part.Reasoning != nil {
multiReasoningRunes += len([]rune(strings.TrimSpace(part.Reasoning.Text)))
}
default:
otherParts++
}
}
reasoningRunes := len([]rune(strings.TrimSpace(msg.ReasoningContent))) + multiReasoningRunes
fields := []string{
fmt.Sprintf("role=%s", msg.Role),
fmt.Sprintf("content_runes=%d", len([]rune(strings.TrimSpace(msg.Content)))+multiTextRunes),
fmt.Sprintf("reasoning_runes=%d", reasoningRunes),
fmt.Sprintf("text_parts=%d", textParts),
fmt.Sprintf("reasoning_parts=%d", reasoningParts),
fmt.Sprintf("other_parts=%d", otherParts),
fmt.Sprintf("tool_calls=%d", len(msg.ToolCalls)),
}
if msg.ResponseMeta != nil {
fields = append(fields, fmt.Sprintf("finish_reason=%q", msg.ResponseMeta.FinishReason))
if usage := msg.ResponseMeta.Usage; usage != nil {
fields = append(fields,
fmt.Sprintf("prompt_tokens=%d", usage.PromptTokens),
fmt.Sprintf("completion_tokens=%d", usage.CompletionTokens),
fmt.Sprintf("total_tokens=%d", usage.TotalTokens),
fmt.Sprintf("reasoning_tokens=%d", usage.CompletionTokensDetails.ReasoningTokens),
)
}
}
if reasoningRunes > 0 {
fields = append(fields, "hint=模型返回了 reasoning_content 但没有返回可作为摘要正文的 content;请检查 DeepSeek thinking 是否已在摘要请求中关闭")
}
return strings.Join(fields, " ")
}
func agenticSummaryEmptyDiagnostics(msg *schema.AgenticMessage) string {
if msg == nil {
return "model returned nil agentic message"
}
var textBlocks, reasoningBlocks, otherBlocks int
var textRunes, reasoningRunes int
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
switch {
case block.AssistantGenText != nil:
textBlocks++
textRunes += len([]rune(strings.TrimSpace(block.AssistantGenText.Text)))
case block.Reasoning != nil:
reasoningBlocks++
reasoningRunes += len([]rune(strings.TrimSpace(block.Reasoning.Text)))
default:
otherBlocks++
}
}
fields := []string{
fmt.Sprintf("role=%s", msg.Role),
fmt.Sprintf("content_runes=%d", textRunes),
fmt.Sprintf("reasoning_runes=%d", reasoningRunes),
fmt.Sprintf("text_blocks=%d", textBlocks),
fmt.Sprintf("reasoning_blocks=%d", reasoningBlocks),
fmt.Sprintf("other_blocks=%d", otherBlocks),
}
if msg.ResponseMeta != nil && msg.ResponseMeta.TokenUsage != nil {
usage := msg.ResponseMeta.TokenUsage
fields = append(fields,
fmt.Sprintf("prompt_tokens=%d", usage.PromptTokens),
fmt.Sprintf("completion_tokens=%d", usage.CompletionTokens),
fmt.Sprintf("total_tokens=%d", usage.TotalTokens),
fmt.Sprintf("reasoning_tokens=%d", usage.CompletionTokensDetails.ReasoningTokens),
)
}
if reasoningRunes > 0 {
fields = append(fields, "hint=模型返回了 reasoning block 但没有返回可作为摘要正文的 text block;请检查 DeepSeek thinking 是否已在摘要请求中关闭")
}
return strings.Join(fields, " ")
}
@@ -0,0 +1,101 @@
package multiagent
import (
"context"
"strings"
"testing"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
type guardClassicSummaryModel struct {
out *schema.Message
}
func (m *guardClassicSummaryModel) Generate(context.Context, []*schema.Message, ...model.Option) (*schema.Message, error) {
return m.out, nil
}
func (m *guardClassicSummaryModel) Stream(context.Context, []*schema.Message, ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return schema.StreamReaderFromArray([]*schema.Message{m.out}), nil
}
func TestNonEmptySummaryChatModelReportsEmptyContentDiagnostics(t *testing.T) {
msg := schema.AssistantMessage("", nil)
msg.ReasoningContent = "只返回了思考,没有最终摘要"
msg.ResponseMeta = &schema.ResponseMeta{
FinishReason: "stop",
Usage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 3,
TotalTokens: 13,
CompletionTokensDetails: schema.CompletionTokensDetails{
ReasoningTokens: 3,
},
},
}
_, err := newNonEmptySummaryChatModel(&guardClassicSummaryModel{out: msg}).Generate(context.Background(), nil)
if err == nil {
t.Fatal("expected empty summary error")
}
text := err.Error()
for _, want := range []string{
"summary content is empty",
"reasoning_runes=",
`finish_reason="stop"`,
"reasoning_tokens=3",
"DeepSeek thinking",
} {
if !strings.Contains(text, want) {
t.Fatalf("error missing %q:\n%s", want, text)
}
}
}
type guardAgenticSummaryModel struct {
out *schema.AgenticMessage
}
func (m *guardAgenticSummaryModel) Generate(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.AgenticMessage, error) {
return m.out, nil
}
func (m *guardAgenticSummaryModel) Stream(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
return schema.StreamReaderFromArray([]*schema.AgenticMessage{m.out}), nil
}
func TestNonEmptyAgenticSummaryModelReportsEmptyContentDiagnostics(t *testing.T) {
msg := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.Reasoning{Text: "只返回了思考,没有最终摘要"}),
},
ResponseMeta: &schema.AgenticResponseMeta{
TokenUsage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 3,
TotalTokens: 13,
CompletionTokensDetails: schema.CompletionTokensDetails{
ReasoningTokens: 3,
},
},
},
}
_, err := newNonEmptyAgenticSummaryModel(&guardAgenticSummaryModel{out: msg}).Generate(context.Background(), nil)
if err == nil {
t.Fatal("expected empty summary error")
}
text := err.Error()
for _, want := range []string{
"summary content is empty",
"reasoning_runes=",
"reasoning_blocks=1",
"reasoning_tokens=3",
"DeepSeek thinking",
} {
if !strings.Contains(text, want) {
t.Fatalf("error missing %q:\n%s", want, text)
}
}
}
@@ -21,12 +21,15 @@ func shouldDisableDeepSeekThinkingForSummarization(oa *config.OpenAIConfig) bool
if oa == nil {
return false
}
if oa.IsDeepSeekEndpointOrModel() {
return true
}
profile := strings.ToLower(strings.TrimSpace(oa.Reasoning.ProfileEffective()))
switch profile {
case "deepseek", "deepseek_compat":
return true
case "", "auto":
return oa.IsDeepSeekEndpointOrModel()
return false
default:
return false
}
@@ -52,7 +52,7 @@ func TestStripReasoningFromSummarizationPayloadDisablesDeepSeekThinking(t *testi
}
}
func TestStripReasoningFromSummarizationPayloadHonorsOpenAICompatProfile(t *testing.T) {
func TestStripReasoningFromSummarizationPayloadDisablesDeepSeekEndpointEvenWithOpenAICompatProfile(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
@@ -66,8 +66,30 @@ func TestStripReasoningFromSummarizationPayloadHonorsOpenAICompatProfile(t *test
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected reasoning_effort stripped, got %s", s)
}
if !strings.Contains(s, `"thinking":{"type":"disabled"}`) {
t.Fatalf("expected official DeepSeek endpoint thinking disabled, got %s", s)
}
}
func TestStripReasoningFromSummarizationPayloadHonorsOpenAICompatProfileForNonDeepSeekEndpoint(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://compatible.example.com/v1",
Model: "deepseek-v4-flash",
Reasoning: config.OpenAIReasoningConfig{
Profile: "openai_compat",
},
}
out, err := stripReasoningFromSummarizationPayload(in, oa)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected OpenAI-compatible profile to strip reasoning fields, got %s", s)
t.Fatalf("expected non-DeepSeek OpenAI-compatible endpoint to strip reasoning fields, got %s", s)
}
}
@@ -13,6 +13,7 @@ import (
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
@@ -385,6 +386,84 @@ func TestSummarizeFinalize_MergesSystemMessages(t *testing.T) {
}
}
func TestEinoSummarizationMiddlewareRetriesWhenSummaryModelReturnsEmpty(t *testing.T) {
t.Parallel()
ctx := context.Background()
emit := false
summaryModel := &capturingClassicChatModel{outputs: []*schema.Message{
schema.AssistantMessage("", nil),
schema.AssistantMessage("<summary>有效摘要:继续验证 SQL 注入路径</summary>", nil),
}}
appCfg := &config.Config{}
appCfg.OpenAI.Model = "gpt-4o"
appCfg.OpenAI.MaxTotalTokens = 5000
appCfg.Database.Path = filepath.Join(t.TempDir(), "cyberstrike.db")
mwCfg := &config.MultiAgentEinoMiddlewareConfig{
SummarizationEmitInternalEvents: &emit,
SummarizationOutputReserveTokens: 1024,
}
mw, err := newEinoSummarizationMiddleware(ctx, summaryModel, appCfg, mwCfg, "conv-empty-summary", nil, "", nil)
if err != nil {
t.Fatalf("newEinoSummarizationMiddleware: %v", err)
}
state := &adk.ChatModelAgentState{Messages: []adk.Message{
schema.SystemMessage("system root"),
schema.UserMessage("授权范围 example.com\n" + strings.Repeat("历史扫描输出 ", 12000)),
schema.AssistantMessage("已记录范围", nil),
schema.UserMessage("继续验证 SQL 注入路径"),
}}
_, after, err := mw.BeforeModelRewriteState(ctx, state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState should retry instead of failing on empty summary: %v", err)
}
if after == nil {
t.Fatal("after state is nil")
}
if summaryModel.calls < 2 {
t.Fatalf("summary model calls=%d, want retry after empty output", summaryModel.calls)
}
joined := joinClassicMessageContent(after.Messages)
for _, want := range []string{"有效摘要", "继续验证 SQL 注入路径", "原始用户输入与约束账本"} {
if !strings.Contains(joined, want) {
t.Fatalf("retried compacted context missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "本地压缩摘要") {
t.Fatalf("local fallback should not be used:\n%s", joined)
}
}
type capturingClassicChatModel struct {
output *schema.Message
outputs []*schema.Message
calls int
}
func (m *capturingClassicChatModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
m.calls++
if len(m.outputs) > 0 {
idx := m.calls - 1
if idx >= len(m.outputs) {
idx = len(m.outputs) - 1
}
return m.outputs[idx], nil
}
if m.output != nil {
return m.output, nil
}
return schema.AssistantMessage("classic answer", nil), nil
}
func (m *capturingClassicChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
msg, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.Message{msg}), nil
}
// assertNoOrphanTool 断言消息列表里的每个 role=tool 消息都能在更前面找到一个
// assistant(tool_calls) 提供相同 ID,否则说明产生了孤儿(触发 LLM 400 的根因)。
func assertNoOrphanTool(t *testing.T, msgs []adk.Message) {