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 <cursoragent@cursor.com>
This commit is contained in:
公明
2026-08-19 00:29:12 +08:00
committed by temp
co-authored by Cursor
parent ac6e04a94c
commit 24d06c5220
13 changed files with 522 additions and 89 deletions
+93 -51
View File
@@ -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 == "<nil>" {
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
@@ -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++ {
+29 -10
View File
@@ -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 {
@@ -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)
@@ -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,
@@ -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
@@ -0,0 +1,91 @@
package multiagent
import (
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// concatToolResultChunks 按 Eino 原生语义合并工具结果流:
// - 同一 CallIDEventSender 一 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
}
@@ -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])
}
}
@@ -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()
@@ -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{
+27
View File
@@ -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,
+28 -5
View File
@@ -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 前全量正文)
@@ -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/);
});