diff --git a/internal/multiagent/eino_exit_fallback_test.go b/internal/multiagent/eino_exit_fallback_test.go index 6478c165..3e3936a2 100644 --- a/internal/multiagent/eino_exit_fallback_test.go +++ b/internal/multiagent/eino_exit_fallback_test.go @@ -1,6 +1,7 @@ package multiagent import ( + "encoding/json" "testing" "github.com/cloudwego/eino/schema" @@ -119,6 +120,89 @@ func TestEinoRunResultBuilderFinalFallsBackToPlainAssistantTrace(t *testing.T) { } } +func TestEinoRunResultBuilderPrefersExitFinalOverAssistantIntro(t *testing.T) { + intro := "本轮渗透收束完成。交付终审报告:" + report := "## 终审报告\n目标 alvin-whn.top 已完成 getshell 验证。" + asst := schema.AssistantMessage(intro, []schema.ToolCall{{ + ID: "exit-1", + Type: "function", + Function: schema.FunctionCall{ + Name: "exit", + Arguments: `{"final_result":` + mustJSONString(report) + `}`, + }, + }}) + runMessages := newEinoRunMessageAccumulator(nil) + runMessages.Append(schema.UserMessage("继续")) + runMessages.Append(asst) + runMessages.Append(toolExitMsg(report, "exit-1")) + + assistantOutput := newEinoAssistantOutputAccumulator("supervisor") + assistantOutput.RecordMainAssistant("cyberstrike-supervisor", intro) + + got := newEinoRunResultBuilder(einoRunResultBuilderConfig{ + OrchMode: "supervisor", + EmptyHint: "empty", + RunMessages: runMessages, + AssistantOutput: assistantOutput, + }).BuildFinal() + + want := intro + "\n\n" + report + if got.Response != want { + t.Fatalf("response = %q, want %q", got.Response, want) + } +} + +func TestEinoRunResultBuilderPrefersExitFinalFromArgsWhenToolContentMissing(t *testing.T) { + intro := "交付终审报告:" + report := "full report body" + asst := schema.AssistantMessage(intro, []schema.ToolCall{{ + ID: "exit-1", + Type: "function", + Function: schema.FunctionCall{ + Name: "exit", + Arguments: `{"final_result":` + mustJSONString(report) + `}`, + }, + }}) + runMessages := newEinoRunMessageAccumulator(nil) + runMessages.Append(asst) + runMessages.Append(toolExitMsg("", "exit-1")) + + assistantOutput := newEinoAssistantOutputAccumulator("supervisor") + assistantOutput.RecordMainAssistant("cyberstrike-supervisor", intro) + + got := newEinoRunResultBuilder(einoRunResultBuilderConfig{ + OrchMode: "supervisor", + EmptyHint: "empty", + RunMessages: runMessages, + AssistantOutput: assistantOutput, + }).BuildFinal() + + want := intro + "\n\n" + report + if got.Response != want { + t.Fatalf("response = %q, want %q", got.Response, want) + } +} + +func TestEinoMergeAssistantIntroWithExitFinal(t *testing.T) { + if got := einoMergeAssistantIntroWithExitFinal("", "final"); got != "final" { + t.Fatalf("empty intro: %q", got) + } + if got := einoMergeAssistantIntroWithExitFinal("same", "same"); got != "same" { + t.Fatalf("identical: %q", got) + } + if got := einoMergeAssistantIntroWithExitFinal("intro", "intro\n\nbody"); got != "intro\n\nbody" { + t.Fatalf("contained intro: %q", got) + } +} + +func mustJSONString(s string) string { + b, err := json.Marshal(s) + if err != nil { + panic(err) + } + return string(b) +} + func toolExitMsg(content, callID string) *schema.Message { m := schema.ToolMessage(content, callID) m.ToolName = "exit" diff --git a/internal/multiagent/eino_run_result_builder.go b/internal/multiagent/eino_run_result_builder.go index ecfb9cfe..5583ce05 100644 --- a/internal/multiagent/eino_run_result_builder.go +++ b/internal/multiagent/eino_run_result_builder.go @@ -104,7 +104,11 @@ func buildEinoRunResultFromAccumulated( cleaned = UnwrapPlanExecuteUserText(cleaned) } } - if cleaned == "" { + // exit.final_result 是正式交付物:即使助手正文已有过渡语(如「交付终审报告:」), + // 也必须优先采用 exit 内容,避免 supervisor 等模式只展示空壳开场白。 + if exitFinal := strings.TrimSpace(einoExtractExitDeliverableFromMsgs(runAccumulatedMsgs)); exitFinal != "" { + cleaned = einoMergeAssistantIntroWithExitFinal(cleaned, exitFinal) + } else if cleaned == "" { if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" { cleaned = fb if orchMode == "plan_execute" { @@ -149,15 +153,10 @@ func markModelFacingTraceForPersistence(msgs []adk.Message) []adk.Message { return out } -// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK -// 原生消息轨迹中回填用户可见回复。这里保持克制:只采纳倒序最近的可交付终态, -// 避免把工具调用前的过渡语或子任务过程误升为最终回复。 -// -// 可交付终态: -// - exit 工具输出; -// - assistant 调用 exit 时 arguments.final_result; -// - 没有后续普通工具结果截断的纯 assistant 正文。 -func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string { +// einoExtractExitDeliverableFromMsgs 从轨迹中提取当前轮次的 exit 正式交付物 +//(工具输出或 assistant 调用 exit 时的 arguments.final_result)。 +// 若更靠近末尾出现了非 exit 的工具结果,则认为 exit 不是终态交付。 +func einoExtractExitDeliverableFromMsgs(msgs []adk.Message) string { for i := len(msgs) - 1; i >= 0; i-- { m := msgs[i] if m == nil { @@ -170,12 +169,76 @@ func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string { if content != "" && !strings.HasPrefix(content, einomcp.ToolErrorPrefix) { return content } + // exit 工具输出为空时,继续向前从 assistant 参数回填。 + continue } return "" case schema.Assistant: if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" { return s } + if einoAssistantHasNonExitToolCall(m) { + return "" + } + } + } + return "" +} + +func einoAssistantHasNonExitToolCall(msg *schema.Message) bool { + if msg == nil { + return false + } + for _, tc := range msg.ToolCalls { + if !strings.EqualFold(strings.TrimSpace(tc.Function.Name), adk.ToolInfoExit.Name) { + return true + } + } + return false +} + +// einoMergeAssistantIntroWithExitFinal 合并助手过渡语与 exit 交付正文。 +// exit 内容优先;若助手正文只是开场白且未被 exit 文本包含,则前置保留。 +func einoMergeAssistantIntroWithExitFinal(assistant, exitFinal string) string { + assistant = strings.TrimSpace(assistant) + exitFinal = strings.TrimSpace(exitFinal) + if exitFinal == "" { + return assistant + } + if assistant == "" || assistant == exitFinal { + return exitFinal + } + if strings.Contains(exitFinal, assistant) { + return exitFinal + } + if strings.Contains(assistant, exitFinal) { + return assistant + } + return assistant + "\n\n" + exitFinal +} + +// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK +// 原生消息轨迹中回填用户可见回复。这里保持克制:只采纳倒序最近的可交付终态, +// 避免把工具调用前的过渡语或子任务过程误升为最终回复。 +// +// 可交付终态: +// - exit 工具输出; +// - assistant 调用 exit 时 arguments.final_result; +// - 没有后续普通工具结果截断的纯 assistant 正文。 +func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string { + if s := einoExtractExitDeliverableFromMsgs(msgs); s != "" { + return s + } + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + if m == nil { + continue + } + switch m.Role { + case schema.Tool: + // 最近一条是普通工具结果:说明助手尚未给出最终正文,勿回退到更早过程语。 + return "" + case schema.Assistant: if len(m.ToolCalls) == 0 { if content := strings.TrimSpace(m.Content); content != "" { return content