diff --git a/internal/multiagent/eino_adk_run_loop.go b/internal/multiagent/eino_adk_run_loop.go index 44d15b10..825f3c95 100644 --- a/internal/multiagent/eino_adk_run_loop.go +++ b/internal/multiagent/eino_adk_run_loop.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "sync" "sync/atomic" @@ -315,10 +316,12 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs if len(preview) > 200 { preview = preview[:200] + "..." } + backgroundRunning := isErr && isMCPBackgroundWaitResult(content) + displayIsErr := isErr && !backgroundRunning data := map[string]interface{}{ "toolName": toolName, - "success": !isErr, - "isError": isErr, + "success": !displayIsErr, + "isError": displayIsErr, "result": content, "resultPreview": preview, "agentFacing": true, // 与 reduction 后送入 ChatModel 的正文一致,供前端展示 @@ -327,6 +330,13 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs "einoRole": einoRoleTag(agentName), "source": "eino", } + if backgroundRunning { + data["status"] = "background_running" + data["modelFacingIsError"] = isErr + if execID := mcpExecutionIDFromWaitResult(content); execID != "" { + data["executionId"] = execID + } + } tid := strings.TrimSpace(toolCallID) if tid == "" { if inferred, ok := popNextPendingForAgent(agentName); ok { @@ -347,8 +357,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs data["toolCallId"] = tid toolCallID = tid } - recordPendingExecuteStdoutDup(toolName, content, isErr) - recordEinoADKFilesystemToolMonitor(ctx, args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, args.MCPExecutionBinder, toolName, toolCallID, runAccumulatedMsgs, content, isErr) + recordPendingExecuteStdoutDup(toolName, content, displayIsErr) + recordEinoADKFilesystemToolMonitor(ctx, args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, args.MCPExecutionBinder, toolName, toolCallID, runAccumulatedMsgs, content, displayIsErr) if args.FilesystemMonitorAgent != nil && args.MCPExecutionBinder != nil { if execID := args.MCPExecutionBinder.ExecutionID(toolCallID); execID != "" { args.FilesystemMonitorAgent.UpdateMCPExecutionDisplayResult(execID, content) @@ -1242,6 +1252,40 @@ func einoToolResultIsError(toolName, content string) bool { return false } +func isMCPBackgroundWaitResult(content string) bool { + text := strings.ToLower(strings.TrimSpace(content)) + if text == "" { + return false + } + hasExecutionID := strings.Contains(text, "execution_id:") || strings.Contains(text, `"execution_id"`) + hasRunningStatus := strings.Contains(text, "status: running") || strings.Contains(text, "status: queued") || + strings.Contains(text, `"status": "running"`) || strings.Contains(text, `"status":"running"`) || + strings.Contains(text, `"status": "queued"`) || strings.Contains(text, `"status":"queued"`) + hasSoftWaitSignal := strings.Contains(text, "工具已提交到后台执行") || + strings.Contains(text, "本次等待已到达") || + strings.Contains(text, "wait_timeout:") || + strings.Contains(text, "background execution") || + strings.Contains(text, "still running") || + strings.Contains(text, "仍未完成") + return hasExecutionID && hasRunningStatus && hasSoftWaitSignal +} + +func mcpExecutionIDFromWaitResult(content string) string { + re := regexp.MustCompile(`(?i)"?execution_id"?\s*[:=]\s*"?([0-9a-f]{8}-[0-9a-f-]{12,})"?`) + if m := re.FindStringSubmatch(content); len(m) > 1 { + return strings.TrimSpace(m[1]) + } + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + lower := strings.ToLower(line) + if !strings.HasPrefix(lower, "execution_id:") { + continue + } + return strings.Trim(strings.TrimSpace(line[len("execution_id:"):]), `"'`) + } + return "" +} + // einoToolResultBody 去掉工具错误前缀,返回展示/持久化正文。 func einoToolResultBody(content string) string { if strings.HasPrefix(content, einomcp.ToolErrorPrefix) { diff --git a/internal/multiagent/eino_execute_failure_format_test.go b/internal/multiagent/eino_execute_failure_format_test.go index c83e2853..5b831d81 100644 --- a/internal/multiagent/eino_execute_failure_format_test.go +++ b/internal/multiagent/eino_execute_failure_format_test.go @@ -112,3 +112,41 @@ func TestFriendlyEinoExecuteInvokeTail(t *testing.T) { t.Fatal("unexpected error should get tail") } } + +func TestMCPBackgroundWaitResultIsDisplayRunning(t *testing.T) { + body := `工具已提交到后台执行,但本次等待已到达上限。 + +execution_id: 3eaaa391-050b-4be1-a870-48a855923cb7 +tool: exec +status: running +wait_timeout: 10s +elapsed: 10s + +你可以继续推理、改用其他工具,或调用 wait_tool_execution 继续等待该 execution_id;也可以调用 cancel_tool_execution 取消。` + modelFacing := einomcp.ToolErrorPrefix + body + if !einoToolResultIsError("exec", modelFacing) { + t.Fatal("soft wait timeout must remain model-facing tool error") + } + if !isMCPBackgroundWaitResult(einoToolResultBody(modelFacing)) { + t.Fatal("soft wait timeout should display as background running") + } + if got := mcpExecutionIDFromWaitResult(einoToolResultBody(modelFacing)); got != "3eaaa391-050b-4be1-a870-48a855923cb7" { + t.Fatalf("execution id = %q", got) + } + if isMCPBackgroundWaitResult("execution_id: abc\nstatus: failed\nerror: boom") { + t.Fatal("real failures must not display as background running") + } + jsonBody := `{ + "execution_id": "e98baefc-72eb-4a7e-9091-9be179a75d71", + "tool": "exec", + "status": "running" +} + +本次等待已到达 timeout_seconds,上述 execution 仍未完成。可继续等待、取消,或采用其他步骤。` + if !isMCPBackgroundWaitResult(jsonBody) { + t.Fatal("json wait_tool_execution timeout should display as background running") + } + if got := mcpExecutionIDFromWaitResult(jsonBody); got != "e98baefc-72eb-4a7e-9091-9be179a75d71" { + t.Fatalf("json execution id = %q", got) + } +} diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index 7020ac78..5e328d08 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -715,7 +715,7 @@ func historyToMessages(history []agent.ChatMessage, appCfg *config.Config, mwCfg opts = append(opts, schema.WithToolName(tn)) } content := h.Content - if !h.ModelFacingTrace { + if !h.ModelFacingTrace || (toolContentMax > 0 && len(content) > toolContentMax) { content = normalizeRestoredToolContent(content, toolContentMax) } raw = append(raw, schema.ToolMessage(content, h.ToolCallID, opts...)) diff --git a/internal/multiagent/runner_reasoning_history_test.go b/internal/multiagent/runner_reasoning_history_test.go index 792582ef..6dc3f0ca 100644 --- a/internal/multiagent/runner_reasoning_history_test.go +++ b/internal/multiagent/runner_reasoning_history_test.go @@ -44,7 +44,7 @@ func TestHistoryToMessagesNormalizesLegacyRawToolOutput(t *testing.T) { func TestHistoryToMessagesRestoresModelFacingTraceByteForByte(t *testing.T) { mw := &config.MultiAgentEinoMiddlewareConfig{ - ReductionMaxLengthForTrunc: 128, + ReductionMaxLengthForTrunc: 4096, LatestUserMessageMaxRunes: 64, } userContent := strings.Repeat("model-facing-user-", 100) @@ -66,6 +66,24 @@ func TestHistoryToMessagesRestoresModelFacingTraceByteForByte(t *testing.T) { } } +func TestHistoryToMessagesCapsOversizedModelFacingToolTrace(t *testing.T) { + mw := &config.MultiAgentEinoMiddlewareConfig{ReductionMaxLengthForTrunc: 128} + h := []agent.ChatMessage{ + {Role: "assistant", ModelFacingTrace: true, ToolCalls: []agent.ToolCall{{ID: "t1", Type: "function", Function: agent.FunctionCall{Name: "exec"}}}}, + {Role: "tool", ToolCallID: "t1", ToolName: "exec", Content: strings.Repeat("model-facing-tool-", 1000), ModelFacingTrace: true}, + } + msgs := historyToMessages(h, nil, mw) + if len(msgs) != 2 { + t.Fatalf("len=%d", len(msgs)) + } + if len(msgs[1].Content) > 128 { + t.Fatalf("restored model-facing tool bytes=%d, want <=128", len(msgs[1].Content)) + } + if !strings.Contains(msgs[1].Content, "legacy tool output discarded") { + t.Fatalf("missing cap marker: %q", msgs[1].Content) + } +} + func TestHistoryToMessagesNeverReinjectsRawOversizedUserFallback(t *testing.T) { appCfg := &config.Config{OpenAI: config.OpenAIConfig{MaxTotalTokens: 10000}} mw := &config.MultiAgentEinoMiddlewareConfig{LatestUserMessageMaxRunes: 8000}