diff --git a/internal/multiagent/eino_exit_fallback_test.go b/internal/multiagent/eino_exit_fallback_test.go index 57bba91d..6478c165 100644 --- a/internal/multiagent/eino_exit_fallback_test.go +++ b/internal/multiagent/eino_exit_fallback_test.go @@ -55,6 +55,70 @@ func TestEinoExtractFallbackAssistantFromMsgs_prefersToolOverEarlierAssistant(t } } +func TestEinoExtractFallbackAssistantFromMsgs_plainAssistant(t *testing.T) { + msgs := []*schema.Message{ + schema.UserMessage("hi"), + schema.AssistantMessage("plain answer", nil), + } + if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "plain answer" { + t.Fatalf("got %q", got) + } +} + +func TestEinoExtractFallbackAssistantFromMsgs_finalAssistantAfterToolResult(t *testing.T) { + msgs := []*schema.Message{ + schema.UserMessage("hi"), + schema.AssistantMessage("", []schema.ToolCall{{ + ID: "call-1", + Type: "function", + Function: schema.FunctionCall{ + Name: "execute", + Arguments: `{"command":"pwd"}`, + }, + }}), + schema.ToolMessage("/tmp", "call-1", schema.WithToolName("execute")), + schema.AssistantMessage("final after tool", nil), + } + if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "final after tool" { + t.Fatalf("got %q", got) + } +} + +func TestEinoExtractFallbackAssistantFromMsgs_doesNotUseAssistantBeforeUnfinishedToolResult(t *testing.T) { + msgs := []*schema.Message{ + schema.UserMessage("hi"), + schema.AssistantMessage("I will inspect that.", nil), + schema.AssistantMessage("", []schema.ToolCall{{ + ID: "call-1", + Type: "function", + Function: schema.FunctionCall{ + Name: "execute", + Arguments: `{"command":"pwd"}`, + }, + }}), + schema.ToolMessage("/tmp", "call-1", schema.WithToolName("execute")), + } + if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "" { + t.Fatalf("got %q, want empty", got) + } +} + +func TestEinoRunResultBuilderFinalFallsBackToPlainAssistantTrace(t *testing.T) { + runMessages := newEinoRunMessageAccumulator(nil) + runMessages.Append(schema.UserMessage("hi")) + runMessages.Append(schema.AssistantMessage("plain answer", nil)) + + got := newEinoRunResultBuilder(einoRunResultBuilderConfig{ + OrchMode: "deep", + EmptyHint: "empty", + RunMessages: runMessages, + }).BuildFinal() + + if got.Response != "plain answer" { + t.Fatalf("response = %q, want plain answer", got.Response) + } +} + func toolExitMsg(content, callID string) *schema.Message { m := schema.ToolMessage(content, callID) m.ToolName = "exit" diff --git a/internal/multiagent/eino_run_message_accumulator.go b/internal/multiagent/eino_run_message_accumulator.go index 2f4f7fbb..b9e410ff 100644 --- a/internal/multiagent/eino_run_message_accumulator.go +++ b/internal/multiagent/eino_run_message_accumulator.go @@ -57,6 +57,16 @@ func (a *einoRunMessageAccumulator) Messages() []adk.Message { return a.msgs } +func (a *einoRunMessageAccumulator) NewMessages() []adk.Message { + if a == nil { + return nil + } + if a.baseCount < 0 || a.baseCount >= len(a.msgs) { + return nil + } + return a.msgs[a.baseCount:] +} + func (a *einoRunMessageAccumulator) BaseCount() int { if a == nil { return 0 diff --git a/internal/multiagent/eino_run_message_accumulator_test.go b/internal/multiagent/eino_run_message_accumulator_test.go index 68751ff8..beffb673 100644 --- a/internal/multiagent/eino_run_message_accumulator_test.go +++ b/internal/multiagent/eino_run_message_accumulator_test.go @@ -27,6 +27,10 @@ func TestEinoRunMessageAccumulatorTracksBaseAndAppends(t *testing.T) { if len(msgs) != 2 || msgs[1].Role != schema.Assistant || msgs[1].Content != "hello" { t.Fatalf("messages = %#v", msgs) } + newMsgs := acc.NewMessages() + if len(newMsgs) != 1 || newMsgs[0].Content != "hello" { + t.Fatalf("new messages = %#v", newMsgs) + } } func TestEinoRunMessageAccumulatorToolMessage(t *testing.T) { diff --git a/internal/multiagent/eino_run_result_builder.go b/internal/multiagent/eino_run_result_builder.go index 958068e5..ecfb9cfe 100644 --- a/internal/multiagent/eino_run_result_builder.go +++ b/internal/multiagent/eino_run_result_builder.go @@ -45,7 +45,7 @@ func (b *einoRunResultBuilder) BuildFinal() *RunResult { func (b *einoRunResultBuilder) build(partial bool) *RunResult { var runMsgs []adk.Message if b.cfg.RunMessages != nil { - runMsgs = b.cfg.RunMessages.Messages() + runMsgs = b.cfg.RunMessages.NewMessages() } var lastAssistant string var lastPlanExecuteExecutor string @@ -107,6 +107,9 @@ func buildEinoRunResultFromAccumulated( if cleaned == "" { if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" { cleaned = fb + if orchMode == "plan_execute" { + cleaned = UnwrapPlanExecuteUserText(cleaned) + } } } cleaned = dedupeRepeatedParagraphs(cleaned, 80) @@ -146,32 +149,38 @@ func markModelFacingTraceForPersistence(msgs []adk.Message) []adk.Message { return out } -// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK 轨迹中回填用户可见回复。 -// 典型场景:监督者仅调用 exit(final_result 落在 Tool 消息中),或工具结果已写入历史但 lastAssistant 未更新。 +// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK +// 原生消息轨迹中回填用户可见回复。这里保持克制:只采纳倒序最近的可交付终态, +// 避免把工具调用前的过渡语或子任务过程误升为最终回复。 // -// 优先级:最后一次 exit 工具输出 → 最后一条含 exit 的助手 tool_calls 参数中的 final_result。 +// 可交付终态: +// - exit 工具输出; +// - assistant 调用 exit 时 arguments.final_result; +// - 没有后续普通工具结果截断的纯 assistant 正文。 func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string { for i := len(msgs) - 1; i >= 0; i-- { m := msgs[i] - if m == nil || m.Role != schema.Tool { + if m == nil { continue } - if !strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) { - continue - } - content := strings.TrimSpace(m.Content) - if content == "" || strings.HasPrefix(content, einomcp.ToolErrorPrefix) { - continue - } - return content - } - for i := len(msgs) - 1; i >= 0; i-- { - m := msgs[i] - if m == nil || m.Role != schema.Assistant { - continue - } - if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" { - return s + switch m.Role { + case schema.Tool: + if strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) { + content := strings.TrimSpace(m.Content) + if content != "" && !strings.HasPrefix(content, einomcp.ToolErrorPrefix) { + return content + } + } + return "" + case schema.Assistant: + if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" { + return s + } + if len(m.ToolCalls) == 0 { + if content := strings.TrimSpace(m.Content); content != "" { + return content + } + } } } return "" diff --git a/internal/multiagent/eino_run_result_builder_test.go b/internal/multiagent/eino_run_result_builder_test.go index 45d28da5..e09e65a7 100644 --- a/internal/multiagent/eino_run_result_builder_test.go +++ b/internal/multiagent/eino_run_result_builder_test.go @@ -55,6 +55,24 @@ func TestEinoRunResultBuilderFinalUsesSnapshots(t *testing.T) { } } +func TestEinoRunResultBuilderFallbackIgnoresBaseHistory(t *testing.T) { + runMessages := newEinoRunMessageAccumulator([]adk.Message{ + schema.UserMessage("previous request"), + schema.AssistantMessage("previous answer", nil), + schema.UserMessage("new request"), + }) + + got := newEinoRunResultBuilder(einoRunResultBuilderConfig{ + OrchMode: "deep", + EmptyHint: "empty", + RunMessages: runMessages, + }).BuildFinal() + + if got.Response != "empty" { + t.Fatalf("response = %q, want empty hint", got.Response) + } +} + func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) { runMessages := newEinoRunMessageAccumulator(nil) runMessages.Append(schema.AssistantMessage(`{"response":"planner text"}`, nil)) @@ -73,3 +91,18 @@ func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) { t.Fatalf("response = %q, want executor text", got.Response) } } + +func TestEinoRunResultBuilderPlanExecuteUnwrapsFallbackAssistant(t *testing.T) { + runMessages := newEinoRunMessageAccumulator(nil) + runMessages.Append(schema.AssistantMessage(`{"response":"fallback executor text"}`, nil)) + + got := newEinoRunResultBuilder(einoRunResultBuilderConfig{ + OrchMode: "plan_execute", + EmptyHint: "empty", + RunMessages: runMessages, + }).BuildFinal() + + if got.Response != "fallback executor text" { + t.Fatalf("response = %q, want fallback executor text", got.Response) + } +}