diff --git a/internal/handler/agent.go b/internal/handler/agent.go
index 80b63c92..c9eeb737 100644
--- a/internal/handler/agent.go
+++ b/internal/handler/agent.go
@@ -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)
diff --git a/internal/handler/batch_queue_executor.go b/internal/handler/batch_queue_executor.go
index 53eed890..5b7f58c7 100644
--- a/internal/handler/batch_queue_executor.go
+++ b/internal/handler/batch_queue_executor.go
@@ -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)
}
diff --git a/internal/handler/eino_single_agent.go b/internal/handler/eino_single_agent.go
index 1691b40d..7e4c7f5d 100644
--- a/internal/handler/eino_single_agent.go
+++ b/internal/handler/eino_single_agent.go
@@ -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
diff --git a/internal/handler/multi_agent.go b/internal/handler/multi_agent.go
index 7da4e76f..5c4bf392 100644
--- a/internal/handler/multi_agent.go
+++ b/internal/handler/multi_agent.go
@@ -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
diff --git a/internal/multiagent/eino_agentic_chat_model_agent_test.go b/internal/multiagent/eino_agentic_chat_model_agent_test.go
index 63761c71..4046af61 100644
--- a/internal/multiagent/eino_agentic_chat_model_agent_test.go
+++ b/internal/multiagent/eino_agentic_chat_model_agent_test.go
@@ -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
}
diff --git a/internal/multiagent/eino_agentic_summarize.go b/internal/multiagent/eino_agentic_summarize.go
index 2be3f455..a2423370 100644
--- a/internal/multiagent/eino_agentic_summarize.go
+++ b/internal/multiagent/eino_agentic_summarize.go
@@ -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)
diff --git a/internal/multiagent/eino_agentic_summarize_test.go b/internal/multiagent/eino_agentic_summarize_test.go
index 4472582c..16b81e7a 100644
--- a/internal/multiagent/eino_agentic_summarize_test.go
+++ b/internal/multiagent/eino_agentic_summarize_test.go
@@ -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("有效摘要:继续验证 SQL 注入路径"),
+ },
+ }
+ 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")
diff --git a/internal/multiagent/eino_run_error_handler.go b/internal/multiagent/eino_run_error_handler.go
index 94575cd9..21bd0c77 100644
--- a/internal/multiagent/eino_run_error_handler.go
+++ b/internal/multiagent/eino_run_error_handler.go
@@ -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
+}
diff --git a/internal/multiagent/eino_run_error_handler_test.go b/internal/multiagent/eino_run_error_handler_test.go
index f25865e5..d1e22e7a 100644
--- a/internal/multiagent/eino_run_error_handler_test.go
+++ b/internal/multiagent/eino_run_error_handler_test.go
@@ -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{}
diff --git a/internal/multiagent/eino_summarize.go b/internal/multiagent/eino_summarize.go
index 9e144ef6..b7cd7e41 100644
--- a/internal/multiagent/eino_summarize.go
+++ b/internal/multiagent/eino_summarize.go
@@ -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 {
diff --git a/internal/multiagent/eino_summarize_model_guard.go b/internal/multiagent/eino_summarize_model_guard.go
new file mode 100644
index 00000000..3a770d95
--- /dev/null
+++ b/internal/multiagent/eino_summarize_model_guard.go
@@ -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, " ")
+}
diff --git a/internal/multiagent/eino_summarize_model_guard_test.go b/internal/multiagent/eino_summarize_model_guard_test.go
new file mode 100644
index 00000000..b52f7c71
--- /dev/null
+++ b/internal/multiagent/eino_summarize_model_guard_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/multiagent/eino_summarize_payload.go b/internal/multiagent/eino_summarize_payload.go
index 0c24259d..0f96a68b 100644
--- a/internal/multiagent/eino_summarize_payload.go
+++ b/internal/multiagent/eino_summarize_payload.go
@@ -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
}
diff --git a/internal/multiagent/eino_summarize_payload_test.go b/internal/multiagent/eino_summarize_payload_test.go
index 273df249..4b6890d1 100644
--- a/internal/multiagent/eino_summarize_payload_test.go
+++ b/internal/multiagent/eino_summarize_payload_test.go
@@ -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)
}
}
diff --git a/internal/multiagent/eino_summarize_test.go b/internal/multiagent/eino_summarize_test.go
index e4a79371..0997c863 100644
--- a/internal/multiagent/eino_summarize_test.go
+++ b/internal/multiagent/eino_summarize_test.go
@@ -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("有效摘要:继续验证 SQL 注入路径", 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) {