From 24d06c5220e82f1f1b79fff2ef161415506c75d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:25:08 +0800 Subject: [PATCH] Fix missing results for parallel Eino tool calls. Merge streaming tool outputs by CallID with ConcatMessages, pair same-name historical results, and FIFO-match duplicate IDs so concurrent nmap 1/2 and 2/2 stay distinct. Co-authored-by: Cursor --- internal/database/conversation.go | 144 +++++++++++------- .../database/process_details_summary_test.go | 67 +++++++- internal/multiagent/eino_adk_run_loop.go | 39 +++-- .../eino_adk_run_loop_stream_test.go | 23 +++ .../multiagent/eino_agentic_event_adapter.go | 5 + .../eino_run_progress_tracker_test.go | 33 ++++ .../multiagent/eino_tool_result_concat.go | 91 +++++++++++ .../eino_tool_result_concat_test.go | 41 +++++ .../eino_tool_result_event_handler.go | 53 ++++--- .../eino_tool_result_event_handler_test.go | 48 ++++++ internal/multiagent/runner.go | 27 ++++ web/static/js/monitor.js | 33 +++- .../js/tool-result-args-backfill.test.cjs | 7 + 13 files changed, 522 insertions(+), 89 deletions(-) create mode 100644 internal/multiagent/eino_tool_result_concat.go create mode 100644 internal/multiagent/eino_tool_result_concat_test.go diff --git a/internal/database/conversation.go b/internal/database/conversation.go index 7ecac55e..52cbbd2a 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -1548,12 +1548,12 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt) seenExecIDs := make(map[string]bool) // A provider may reuse a fallback toolCallId across streaming rounds. Keep a // FIFO per ID instead of a single index so every persisted call gets at most - // one result. Results without a stable ID are kept separate instead of being - // guessed by order; showing no link is safer than linking to the wrong tool. + // one result. ID-less results still attach to an unmatched call with the same + // tool name (parallel nmap 1/2, 2/2 often lose one ID); different tools stay + // unlinked so a leftover preview cannot steal another call's slot. toolIndexesByCallID := make(map[string][]int) lastMatchedToolIndexByCallID := make(map[string]int) matchedToolIndexes := make([]bool, 0) - nextUnmatchedToolIdx := 0 for execRows.Next() { var detailID string var eventType string @@ -1569,24 +1569,10 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt) if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil { continue } - toolName, _ := payload["toolName"].(string) - toolName = strings.TrimSpace(toolName) - toolCallID, _ := payload["toolCallId"].(string) - toolCallID = strings.TrimSpace(toolCallID) - execID, _ := payload["executionId"].(string) - execID = strings.TrimSpace(execID) - status := "" - if eventType == "tool_result" { - if success, ok := payload["success"].(bool); ok { - if success { - status = "completed" - } else { - status = "failed" - } - } else if isErr, ok := payload["isError"].(bool); ok && isErr { - status = "failed" - } - } + toolName := processDetailString(payload, "toolName") + toolCallID := processDetailString(payload, "toolCallId") + execID := processDetailString(payload, "executionId") + status := toolResultStatusFromPayload(payload, eventType) if eventType == "tool_call" { summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{ ProcessDetailID: strings.TrimSpace(detailID), @@ -1603,36 +1589,14 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt) } } if eventType == "tool_result" { - idx := -1 - if toolCallID != "" { - queue := toolIndexesByCallID[toolCallID] - for len(queue) > 0 { - candidate := queue[0] - queue = queue[1:] - if candidate >= 0 && candidate < len(matchedToolIndexes) && !matchedToolIndexes[candidate] { - idx = candidate - break - } - } - toolIndexesByCallID[toolCallID] = queue - if idx < 0 { - // Multiple persisted result events for one call (for example an - // agent-facing reduced result replacing an earlier preview) update - // that call instead of consuming an unrelated FIFO entry. - if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok { - idx = previous - } - } - } - if idx < 0 && toolCallID != "" { - for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] { - nextUnmatchedToolIdx++ - } - if nextUnmatchedToolIdx < len(matchedToolIndexes) { - idx = nextUnmatchedToolIdx - nextUnmatchedToolIdx++ - } - } + idx := matchToolExecutionIndex( + summary.ToolExecutions, + matchedToolIndexes, + toolCallID, + toolName, + toolIndexesByCallID, + lastMatchedToolIndexByCallID, + ) if idx >= 0 && idx < len(summary.ToolExecutions) { matchedToolIndexes[idx] = true if toolCallID != "" { @@ -1648,6 +1612,8 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt) summary.ToolExecutions[idx].ExecutionID = execID if status != "" { summary.ToolExecutions[idx].Status = status + } else { + summary.ToolExecutions[idx].Status = "completed" } } else { summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{ @@ -1704,6 +1670,82 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt) return summary, nil } +func processDetailString(payload map[string]interface{}, key string) string { + if payload == nil { + return "" + } + v, ok := payload[key] + if !ok || v == nil { + return "" + } + s := strings.TrimSpace(fmt.Sprint(v)) + if s == "" || s == "" { + return "" + } + return s +} + +func toolResultStatusFromPayload(payload map[string]interface{}, eventType string) string { + if eventType != "tool_result" { + return "" + } + if status := processDetailString(payload, "status"); strings.EqualFold(status, "background_running") { + return "background_running" + } + if success, ok := payload["success"].(bool); ok { + if success { + return "completed" + } + return "failed" + } + if isErr, ok := payload["isError"].(bool); ok && isErr { + return "failed" + } + return "completed" +} + +func matchToolExecutionIndex( + executions []ProcessDetailsToolExecution, + matched []bool, + toolCallID, toolName string, + toolIndexesByCallID map[string][]int, + lastMatchedToolIndexByCallID map[string]int, +) int { + if toolCallID != "" { + queue := toolIndexesByCallID[toolCallID] + for len(queue) > 0 { + candidate := queue[0] + queue = queue[1:] + if candidate >= 0 && candidate < len(matched) && !matched[candidate] { + toolIndexesByCallID[toolCallID] = queue + return candidate + } + } + toolIndexesByCallID[toolCallID] = queue + if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok { + return previous + } + } + if toolName != "" { + for i := range matched { + if matched[i] { + continue + } + if strings.EqualFold(strings.TrimSpace(executions[i].ToolName), toolName) { + return i + } + } + } + if toolCallID != "" { + for i := range matched { + if !matched[i] { + return i + } + } + } + return -1 +} + // GetProcessDetailsPage 分页获取消息的过程详情(按时间升序)。 func (db *DB) GetProcessDetailsPage(messageID string, limit, offset int) ([]ProcessDetail, int, error) { var total int diff --git a/internal/database/process_details_summary_test.go b/internal/database/process_details_summary_test.go index 200f6b7d..141ea44d 100644 --- a/internal/database/process_details_summary_test.go +++ b/internal/database/process_details_summary_test.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" ) -func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) { +func TestProcessDetailsSummaryDoesNotGuessIDLessResultsOntoDifferentTool(t *testing.T) { db, conversationID, messageID := setupProcessDetailsSummaryTest(t) for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} { if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{ @@ -20,8 +20,8 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) { results := []map[string]interface{}{ {"toolName": "http-framework-test", "toolCallId": "call-1", "success": true}, {"toolName": "http-framework-test", "toolCallId": "call-2", "success": true}, - {"toolName": "http-framework-test", "success": true}, - {"toolName": "http-framework-test", "success": true}, + {"toolName": "other-tool", "success": true}, + {"toolName": "other-tool", "success": true}, } var resultIDs []string for _, result := range results { @@ -53,12 +53,71 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) { } } for i, execution := range summary.ToolExecutions[4:] { - if execution.Status != "completed" || execution.ToolCallID != "" { + if execution.Status != "completed" || execution.ToolCallID != "" || execution.ToolName != "other-tool" { t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution) } } } +func TestProcessDetailsSummaryPairsIDLessResultsWithSameToolName(t *testing.T) { + db, conversationID, messageID := setupProcessDetailsSummaryTest(t) + for i, id := range []string{"call-1", "call-2"} { + if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{ + "toolName": "nmap", "toolCallId": id, "index": i + 1, "total": 2, + }); err != nil { + t.Fatalf("AddProcessDetail(tool_call): %v", err) + } + } + var resultIDs []string + for i := 0; i < 2; i++ { + resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", map[string]interface{}{ + "toolName": "nmap", "success": true, + }) + if err != nil { + t.Fatalf("AddProcessDetail(tool_result): %v", err) + } + resultIDs = append(resultIDs, resultID) + } + + summary, err := db.GetProcessDetailsSummary(messageID) + if err != nil { + t.Fatalf("GetProcessDetailsSummary: %v", err) + } + if len(summary.ToolExecutions) != 2 { + t.Fatalf("tool executions = %d, want 2", len(summary.ToolExecutions)) + } + for i, execution := range summary.ToolExecutions { + if execution.Status != "completed" { + t.Fatalf("execution %d status = %q, want completed", i, execution.Status) + } + if execution.ResultDetailID != resultIDs[i] { + t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i]) + } + } +} + +func TestProcessDetailsSummaryPairedResultWithoutSuccessIsCompleted(t *testing.T) { + db, conversationID, messageID := setupProcessDetailsSummaryTest(t) + if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{ + "toolName": "nmap", "toolCallId": "call-1", + }); err != nil { + t.Fatalf("AddProcessDetail(tool_call): %v", err) + } + if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", map[string]interface{}{ + "toolName": "nmap", "toolCallId": "call-1", "resultPreview": "open 22", + }); err != nil { + t.Fatalf("AddProcessDetail(tool_result): %v", err) + } + + summary, err := db.GetProcessDetailsSummary(messageID) + if err != nil { + t.Fatalf("GetProcessDetailsSummary: %v", err) + } + if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "completed" { + t.Fatalf("tool executions = %#v, want completed", summary.ToolExecutions) + } +} + func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) { db, conversationID, messageID := setupProcessDetailsSummaryTest(t) for i := 0; i < 2; i++ { diff --git a/internal/multiagent/eino_adk_run_loop.go b/internal/multiagent/eino_adk_run_loop.go index 2b31eebc..3f06ec78 100644 --- a/internal/multiagent/eino_adk_run_loop.go +++ b/internal/multiagent/eino_adk_run_loop.go @@ -443,22 +443,41 @@ func nextAgentEventWithContext(ctx context.Context, iter *adk.AsyncIterator[*adk // recvSchemaMessageStream 消费 ADK Tool 流式结果;ctx 取消时立即返回,避免 amass 等无输出时永久阻塞。 func recvSchemaMessageStream(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (content, toolCallID, toolName string, recvErr error) { - if stream == nil { - return "", "", "", nil + msgs, recvErr := recvSchemaToolResultMessages(ctx, stream) + if len(msgs) == 0 { + return "", "", "", recvErr } - var buf strings.Builder - recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) { - if chunk.Content != "" { - buf.WriteString(chunk.Content) + parts := make([]string, 0, len(msgs)) + for _, msg := range msgs { + if msg == nil { + continue } - if tid := strings.TrimSpace(chunk.ToolCallID); tid != "" { - toolCallID = tid + parts = append(parts, msg.Content) + if id := strings.TrimSpace(msg.ToolCallID); id != "" { + toolCallID = id } - if name := strings.TrimSpace(chunk.ToolName); name != "" { + if name := strings.TrimSpace(msg.ToolName); name != "" { toolName = name } + } + return strings.Join(parts, ""), toolCallID, toolName, recvErr +} + +// recvSchemaToolResultMessages 先收齐 Tool 流,再用 Eino ConcatMessages 合并。 +// EventSender 一 call 一条流时走 ConcatMessages;并行结果被摊平进同一条流时按 CallID 分列再合并。 +func recvSchemaToolResultMessages(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (msgs []*schema.Message, recvErr error) { + if stream == nil { + return nil, nil + } + var chunks []*schema.Message + recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) { + chunks = append(chunks, chunk) }) - return buf.String(), toolCallID, toolName, recvErr + msgs, concatErr := concatToolResultChunks(chunks) + if concatErr != nil && recvErr == nil { + return nil, concatErr + } + return msgs, recvErr } func buildEinoCheckpointID(orchMode string) string { diff --git a/internal/multiagent/eino_adk_run_loop_stream_test.go b/internal/multiagent/eino_adk_run_loop_stream_test.go index 2ca8381a..cb312a8c 100644 --- a/internal/multiagent/eino_adk_run_loop_stream_test.go +++ b/internal/multiagent/eino_adk_run_loop_stream_test.go @@ -30,6 +30,29 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) { } } +func TestRecvSchemaToolResultMessages_SplitsParallelIDs(t *testing.T) { + sr, sw := schema.Pipe[*schema.Message](8) + _ = sw.Send(schema.ToolMessage("one-", "tc-1", schema.WithToolName("nmap")), nil) + _ = sw.Send(schema.ToolMessage("two-", "tc-2", schema.WithToolName("nmap")), nil) + _ = sw.Send(schema.ToolMessage("a", "tc-1", schema.WithToolName("nmap")), nil) + _ = sw.Send(schema.ToolMessage("b", "tc-2", schema.WithToolName("nmap")), nil) + sw.Close() + + msgs, err := recvSchemaToolResultMessages(context.Background(), sr) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("msgs = %#v, want 2", msgs) + } + if msgs[0].ToolCallID != "tc-1" || msgs[0].Content != "one-a" { + t.Fatalf("msg 0 = %#v", msgs[0]) + } + if msgs[1].ToolCallID != "tc-2" || msgs[1].Content != "two-b" { + t.Fatalf("msg 1 = %#v", msgs[1]) + } +} + func TestRecvSchemaMessageStream_CapturesToolName(t *testing.T) { sr, sw := schema.Pipe[*schema.Message](4) _ = sw.Send(schema.ToolMessage("hello", "tc-1", schema.WithToolName("execute")), nil) diff --git a/internal/multiagent/eino_agentic_event_adapter.go b/internal/multiagent/eino_agentic_event_adapter.go index 0b91c49d..1e6b809a 100644 --- a/internal/multiagent/eino_agentic_event_adapter.go +++ b/internal/multiagent/eino_agentic_event_adapter.go @@ -31,6 +31,11 @@ func adaptAgenticEventToEinoEvents(ev *adk.TypedAgentEvent[*schema.AgenticMessag return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})} } if mv.IsStreaming { + // Tool 流保持 1 event ↔ 1 MessageStream,对齐 ADK EventSenderToolWrapper: + // 每个 CallID 在工具包装层就已经是独立事件。这里不能再按 CallID 现场拆成 + // 多条 live pipe——drain 会阻塞读完当前流,交错的并行 chunk 会把另一列写满后死锁。 + // 若上游仍把 ToolsNode 的 MergeStreamReaders 摊成一条流,由 + // concatToolResultChunks 按列 ConcatMessages 恢复。 return []*adk.AgentEvent{base(&adk.AgentOutput{ MessageOutput: &adk.MessageVariant{ IsStreaming: true, diff --git a/internal/multiagent/eino_run_progress_tracker_test.go b/internal/multiagent/eino_run_progress_tracker_test.go index a3e9c635..c754e804 100644 --- a/internal/multiagent/eino_run_progress_tracker_test.go +++ b/internal/multiagent/eino_run_progress_tracker_test.go @@ -98,6 +98,39 @@ func TestEinoRunProgressTrackerDedupesToolCalls(t *testing.T) { } } +func TestEinoRunProgressTrackerDedupesSameToolCallIDsWithDifferentArgs(t *testing.T) { + var toolCalls int + progress := func(eventType, _ string, _ interface{}) { + if eventType == "tool_call" { + toolCalls++ + } + } + tracker := newEinoRunProgressTracker("deep", "lead", "conv-1", progress, nil, nil) + first := &schema.Message{ToolCalls: []schema.ToolCall{{ + ID: "call-1", + Type: "function", + Function: schema.FunctionCall{ + Name: "nmap", + Arguments: `{"host":"10.0.0.1"}`, + }, + }}} + second := &schema.Message{ToolCalls: []schema.ToolCall{{ + ID: "call-1", + Type: "function", + Function: schema.FunctionCall{ + Name: "nmap", + Arguments: `{"host":"10.0.0.1","ports":"1-1024"}`, + }, + }}} + + tracker.EmitToolCalls(first, "lead", nil) + tracker.EmitToolCalls(second, "lead", nil) + + if toolCalls != 1 { + t.Fatalf("tool call events = %d, want 1", toolCalls) + } +} + func TestEinoRunProgressTrackerHidesModelOutputRecoveryToolCalls(t *testing.T) { var eventTypes []string var marked []toolCallPendingInfo diff --git a/internal/multiagent/eino_tool_result_concat.go b/internal/multiagent/eino_tool_result_concat.go new file mode 100644 index 00000000..b8d10545 --- /dev/null +++ b/internal/multiagent/eino_tool_result_concat.go @@ -0,0 +1,91 @@ +package multiagent + +import ( + "fmt" + "strings" + + "github.com/cloudwego/eino/schema" +) + +// concatToolResultChunks 按 Eino 原生语义合并工具结果流: +// - 同一 CallID(EventSender 一 call 一 event):schema.ConcatMessages +// - 并行工具被摊进同一条流(ToolsNode MergeStreamReaders 扁平化后): +// 按 CallID 分列后再 ConcatMessages,等价于 schema.ConcatMessageArray +func concatToolResultChunks(chunks []*schema.Message) ([]*schema.Message, error) { + if len(chunks) == 0 { + return nil, nil + } + if toolResultChunksShareCallID(chunks) { + merged, err := schema.ConcatMessages(chunks) + if err != nil { + return nil, err + } + return []*schema.Message{merged}, nil + } + return concatToolResultChunksByCallID(chunks) +} + +func toolResultChunksShareCallID(chunks []*schema.Message) bool { + id := "" + for _, chunk := range chunks { + if chunk == nil { + continue + } + got := strings.TrimSpace(chunk.ToolCallID) + if got == "" { + continue + } + if id == "" { + id = got + continue + } + if got != id { + return false + } + } + return true +} + +func concatToolResultChunksByCallID(chunks []*schema.Message) ([]*schema.Message, error) { + type column struct { + key string + chunks []*schema.Message + } + var ordered []column + index := make(map[string]int) + lastKey := "" + anon := 0 + for _, chunk := range chunks { + if chunk == nil { + continue + } + key := strings.TrimSpace(chunk.ToolCallID) + if key == "" { + if lastKey != "" { + key = lastKey + } else { + key = fmt.Sprintf("\x00anon-%d", anon) + anon++ + } + } + if idx, ok := index[key]; ok { + ordered[idx].chunks = append(ordered[idx].chunks, chunk) + } else { + index[key] = len(ordered) + ordered = append(ordered, column{key: key, chunks: []*schema.Message{chunk}}) + } + lastKey = key + } + out := make([]*schema.Message, 0, len(ordered)) + for _, col := range ordered { + merged, err := schema.ConcatMessages(col.chunks) + if err != nil { + return nil, err + } + if strings.HasPrefix(col.key, "\x00anon-") { + merged.ToolCallID = "" + } + out = append(out, merged) + } + return out, nil +} diff --git a/internal/multiagent/eino_tool_result_concat_test.go b/internal/multiagent/eino_tool_result_concat_test.go new file mode 100644 index 00000000..cb65313b --- /dev/null +++ b/internal/multiagent/eino_tool_result_concat_test.go @@ -0,0 +1,41 @@ +package multiagent + +import ( + "testing" + + "github.com/cloudwego/eino/schema" +) + +func TestConcatToolResultChunksUsesEinoConcatForSingleCall(t *testing.T) { + got, err := concatToolResultChunks([]*schema.Message{ + schema.ToolMessage("hel", "call-1", schema.WithToolName("execute")), + schema.ToolMessage("lo", "call-1", schema.WithToolName("execute")), + }) + if err != nil { + t.Fatalf("concat: %v", err) + } + if len(got) != 1 || got[0].ToolCallID != "call-1" || got[0].Content != "hello" || got[0].ToolName != "execute" { + t.Fatalf("got = %#v, want one ConcatMessages result", got) + } +} + +func TestConcatToolResultChunksSplitsParallelCalls(t *testing.T) { + got, err := concatToolResultChunks([]*schema.Message{ + schema.ToolMessage("nmap 1/2 ", "call-1", schema.WithToolName("nmap")), + schema.ToolMessage("nmap 2/2 ", "call-2", schema.WithToolName("nmap")), + schema.ToolMessage("22/tcp", "call-1", schema.WithToolName("nmap")), + schema.ToolMessage("80/tcp", "call-2", schema.WithToolName("nmap")), + }) + if err != nil { + t.Fatalf("concat: %v", err) + } + if len(got) != 2 { + t.Fatalf("got = %#v, want two calls", got) + } + if got[0].ToolCallID != "call-1" || got[0].Content != "nmap 1/2 22/tcp" { + t.Fatalf("call-1 = %#v", got[0]) + } + if got[1].ToolCallID != "call-2" || got[1].Content != "nmap 2/2 80/tcp" { + t.Fatalf("call-2 = %#v", got[1]) + } +} diff --git a/internal/multiagent/eino_tool_result_event_handler.go b/internal/multiagent/eino_tool_result_event_handler.go index 2d5a9588..2542e2e1 100644 --- a/internal/multiagent/eino_tool_result_event_handler.go +++ b/internal/multiagent/eino_tool_result_event_handler.go @@ -42,27 +42,42 @@ func (h *einoToolResultEventHandler) HandleStreaming(mv *adk.MessageVariant, age if h == nil || mv == nil || !mv.IsStreaming || mv.MessageStream == nil || mv.Role != schema.Tool { return false } - toolName := strings.TrimSpace(mv.ToolName) - content, streamToolCallID, streamToolName, recvErr := recvSchemaMessageStream(h.ctx, mv.MessageStream) - if toolName == "" { - toolName = streamToolName + defaultName := strings.TrimSpace(mv.ToolName) + msgs, recvErr := recvSchemaToolResultMessages(h.ctx, mv.MessageStream) + if isEinoVoluntaryCancelErr(recvErr) && len(msgs) == 0 { + msgs = []*schema.Message{schema.ToolMessage("已中断并继续,当前工具调用已停止。", "", schema.WithToolName(defaultName))} } - if isEinoVoluntaryCancelErr(recvErr) && strings.TrimSpace(content) == "" { - content = "已中断并继续,当前工具调用已停止。" + if len(msgs) == 0 { + msgs = []*schema.Message{schema.ToolMessage("", "", schema.WithToolName(defaultName))} } - isErr := einoToolResultIsError(toolName, content) || isEinoVoluntaryCancelErr(recvErr) - content = einoToolResultBody(content) - if streamToolCallID != "" && h.runMessages != nil { - h.runMessages.AppendToolMessage(content, streamToolCallID, schema.WithToolName(toolName)) - } - if h.emitter != nil { - h.emitter.Emit(h.ctx, toolName, content, streamToolCallID, isErr, agentName) - } - if recvErr != nil && !isEinoVoluntaryCancelErr(recvErr) && h.logger != nil { - h.logger.Warn("eino tool result stream recv error", - zap.Error(recvErr), - zap.String("agent", agentName), - zap.String("tool", toolName)) + for _, msg := range msgs { + if msg == nil { + continue + } + toolName := strings.TrimSpace(msg.ToolName) + if toolName == "" { + toolName = defaultName + } + content := msg.Content + if isEinoVoluntaryCancelErr(recvErr) && strings.TrimSpace(content) == "" { + content = "已中断并继续,当前工具调用已停止。" + } + isErr := einoToolResultIsError(toolName, content) || isEinoVoluntaryCancelErr(recvErr) + content = einoToolResultBody(content) + toolCallID := strings.TrimSpace(msg.ToolCallID) + if toolCallID != "" && h.runMessages != nil { + h.runMessages.AppendToolMessage(content, toolCallID, schema.WithToolName(toolName)) + } + if h.emitter != nil { + h.emitter.Emit(h.ctx, toolName, content, toolCallID, isErr, agentName) + } + if recvErr != nil && !isEinoVoluntaryCancelErr(recvErr) && h.logger != nil { + h.logger.Warn("eino tool result stream recv error", + zap.Error(recvErr), + zap.String("agent", agentName), + zap.String("tool", toolName), + zap.String("toolCallId", toolCallID)) + } } if recvErr == nil && h.confirmRecovery != nil { h.confirmRecovery() diff --git a/internal/multiagent/eino_tool_result_event_handler_test.go b/internal/multiagent/eino_tool_result_event_handler_test.go index 32e6aedf..e297f971 100644 --- a/internal/multiagent/eino_tool_result_event_handler_test.go +++ b/internal/multiagent/eino_tool_result_event_handler_test.go @@ -59,6 +59,54 @@ func TestEinoToolResultEventHandlerHandlesStreamingToolResult(t *testing.T) { } } +func TestEinoToolResultEventHandlerSplitsParallelStreamingResults(t *testing.T) { + var events []map[string]interface{} + runMessages := newEinoRunMessageAccumulator(nil) + emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{ + ConversationID: "conv-1", + Progress: func(eventType, _ string, data interface{}) { + if eventType != "tool_result" { + return + } + m, _ := data.(map[string]interface{}) + events = append(events, m) + }, + }) + handler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{ + RunMessages: runMessages, + Emitter: emitter, + }) + stream := schema.StreamReaderFromArray([]*schema.Message{ + {Role: schema.Tool, Content: "nmap 1/2 start ", ToolCallID: "call-1", ToolName: "nmap"}, + {Role: schema.Tool, Content: "nmap 2/2 start ", ToolCallID: "call-2", ToolName: "nmap"}, + {Role: schema.Tool, Content: "22/tcp open", ToolCallID: "call-1", ToolName: "nmap"}, + {Role: schema.Tool, Content: "80/tcp open", ToolCallID: "call-2", ToolName: "nmap"}, + }) + mv := &adk.MessageVariant{ + IsStreaming: true, + Role: schema.Tool, + ToolName: "nmap", + MessageStream: stream, + } + + if !handler.HandleStreaming(mv, "worker") { + t.Fatal("streaming tool result was not handled") + } + if len(events) != 2 { + t.Fatalf("events = %#v, want two tool_result", events) + } + if events[0]["toolCallId"] != "call-1" || events[0]["result"] != "nmap 1/2 start 22/tcp open" { + t.Fatalf("first event = %#v", events[0]) + } + if events[1]["toolCallId"] != "call-2" || events[1]["result"] != "nmap 2/2 start 80/tcp open" { + t.Fatalf("second event = %#v", events[1]) + } + msgs := runMessages.Messages() + if len(msgs) != 2 || msgs[0].ToolCallID != "call-1" || msgs[1].ToolCallID != "call-2" { + t.Fatalf("run messages = %#v", msgs) + } +} + func TestEinoToolResultEventHandlerHandlesMaterializedToolResult(t *testing.T) { var event map[string]interface{} emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{ diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index d62bf3ca..4e30d1c1 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -908,10 +908,37 @@ func tryEmitToolCallsOnce( if _, ok := seen[sig]; ok { return } + if idSig := toolCallsStableIDSignature(msg); idSig != "" { + idKey := agentName + "\x1eids\x1e" + idSig + if _, ok := seen[idKey]; ok { + return + } + seen[idKey] = struct{}{} + } seen[sig] = struct{}{} emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, orchMode, progress, subAgentToolStep, mainAgentToolStep, markPending) } +func toolCallsStableIDSignature(msg *schema.Message) string { + if msg == nil || len(msg.ToolCalls) == 0 { + return "" + } + visible := filterVisibleToolCallsForProgress(msg.ToolCalls) + ids := make([]string, 0, len(visible)) + for _, tc := range visible { + id := strings.TrimSpace(tc.ID) + if id == "" { + continue + } + ids = append(ids, id) + } + if len(ids) == 0 { + return "" + } + sort.Strings(ids) + return strings.Join(ids, ";") +} + func emitToolCallsFromMessage( msg *schema.Message, agentName, orchestratorName, conversationID, orchMode string, diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 77926f74..0c26a66c 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -6255,21 +6255,44 @@ function coalesceProcessDetailsToolPairs(details) { createdAt: detail.createdAt, data: Object.assign({}, data) }; - if (id) callsById.set(id, copy); + if (id) { + let list = callsById.get(id); + if (!list) { + list = []; + callsById.set(id, list); + } + list.push(copy); + } fifoCalls.push(copy); out.push(copy); - } else if (et === 'tool_result') { + } else if (et === 'tool_result') { let target = null; if (id && callsById.has(id)) { - target = callsById.get(id); - } else { + const list = callsById.get(id); + while (list.length) { + const candidate = list.shift(); + if (candidate && candidate.data && !candidate.data._mergedResult) { + target = candidate; + break; + } + } + } + if (!target) { + const resultName = String(data.toolName || '').trim().toLowerCase(); + let anyUnmatched = null; for (let j = 0; j < fifoCalls.length; j++) { const c = fifoCalls[j]; - if (c && c.data && !c.data._mergedResult) { + if (!c || !c.data || c.data._mergedResult) continue; + if (!anyUnmatched) anyUnmatched = c; + const callName = String(c.data.toolName || '').trim().toLowerCase(); + if (!resultName || !callName || callName === resultName) { target = c; break; } } + if (!target && id) { + target = anyUnmatched; + } } if (target) { // agentFacing 或较新的 tool_result 覆盖旧合并(历史数据可能含 reduction 前全量正文) diff --git a/web/static/js/tool-result-args-backfill.test.cjs b/web/static/js/tool-result-args-backfill.test.cjs index ba2e6439..c2a8f739 100644 --- a/web/static/js/tool-result-args-backfill.test.cjs +++ b/web/static/js/tool-result-args-backfill.test.cjs @@ -16,3 +16,10 @@ test('历史 process_details 合并时也会从 tool_result 补齐 tool_call 参 assert.match(source, /targetDetail\.data\.argumentsObj = resultArgs;/); assert.match(source, /targetDetail\.data\.arguments = JSON\.stringify\(resultArgs\);/); }); + +test('同一 toolCallId 的多次调用按 FIFO 合并结果,避免后一次覆盖导致结果记录缺失', () => { + const source = fs.readFileSync('web/static/js/monitor.js', 'utf8'); + assert.match(source, /list\.push\(copy\)/); + assert.match(source, /const candidate = list\.shift\(\)/); + assert.match(source, /callName === resultName/); +});