#7383·bifrost

[Bug]: agent mode drops one result when the same auto-executable tool runs twice in one turn

Author: Atharva-KanherkarCreated Sep 21, 2026Updated Sep 22, 2026
Labelsbug

description

in agent mode, when one model turn contains two auto-executable calls to the same tool with different tool call ids, bifrost executes both calls but returns only one result in the mixed-turn content summary. the summary is built with a map keyed by tool name, so the later result overwrites the earlier one.

concrete example: a model batches two charges in one turn, charge("invoice-alpha") and charge("invoice-beta"), plus one approval-gated call. both charges really execute at the mcp server. the response content reports a single charge result. an application that parses the summary to decide what already ran (which is exactly what the agent mode docs tell it to do) sees invoice-alpha as unfinished and charges it again.

which of the two results survives depends on parallel completion order. that exactly one is lost is deterministic.

why it happens

core/mcp/agentadaptors.go stores executed results in a map keyed by tool name:

go
toolResultsMap := make(map[string]interface{})
// ...
toolResultsMap[toolName] = output
mermaid
sequenceDiagram
    participant M as model
    participant B as bifrost
    participant T as mcp tool: charge
    M->>B: charge("alpha") + charge("beta") + review
    par both really execute
        B->>T: charge invoice alpha
        T-->>B: receipt alpha
    and
        B->>T: charge invoice beta
        T-->>B: receipt beta
    end
    Note over B: summary keyed by tool name<br/>beta overwrites alpha
    B-->>M: content shows only beta

steps to reproduce

minimal, no network needed. save as core/mcp/zz_probe_test.go in a bifrost checkout and run go test ./mcp -run TestProbe -count=1:

probe test (fails on current dev)
go
package mcp

import (
	"strings"
	"testing"

	"github.com/maximhq/bifrost/core/schemas"
)

func probeCallsAndResults() ([]schemas.ChatAssistantMessageToolCall, []*schemas.ChatMessage) {
	name := "tools-charge"
	firstID := "call-alpha"
	secondID := "call-beta"
	calls := []schemas.ChatAssistantMessageToolCall{
		{ID: &firstID, Function: schemas.ChatAssistantMessageToolCallFunction{Name: &name}},
		{ID: &secondID, Function: schemas.ChatAssistantMessageToolCallFunction{Name: &name}},
	}
	results := []*schemas.ChatMessage{
		{Role: schemas.ChatMessageRoleTool,
			Content:        &schemas.ChatMessageContent{ContentStr: &[]string{"RESULT_ALPHA"}[0]},
			ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: &firstID}},
		{Role: schemas.ChatMessageRoleTool,
			Content:        &schemas.ChatMessageContent{ContentStr: &[]string{"RESULT_BETA"}[0]},
			ChatToolMessage: &schemas.ChatToolMessage{ToolCallID: &secondID}},
	}
	return calls, results
}

func TestProbeChatKeepsBothSameNameResults(t *testing.T) {
	calls, results := probeCallsAndResults()
	resp := createChatResponseWithExecutedToolsAndNonAutoExecutableCalls(&schemas.BifrostChatResponse{}, results, calls, nil)
	content := resp.Choices[0].ChatNonStreamResponseChoice.Message.Content.ContentStr
	if content == nil {
		t.Fatal("no result summary")
	}
	if !strings.Contains(*content, "RESULT_ALPHA") || !strings.Contains(*content, "RESULT_BETA") {
		t.Fatalf("two distinct call ids executed, but a result is lost: %s", *content)
	}
}

on dev head 1a17949c this fails with:

zz_probe_test.go:38: two distinct call ids executed, but a result is lost: The Output from allowed tools calls is - {"tools-charge":"RESULT_BETA"}

the same failure reproduces end to end through the http transport with a real streamable http mcp server, sqlite config store enabled, and a deterministic upstream, including raw wire captures and a five run matrix where the continuation step repeats the missing side effect every run: full writeup and runner (transcripts/082/reproduce.py).

expected behavior

every executed tool call keeps its own result in the summary, identified by its tool call id.

actual behavior

results are collapsed by tool name. one executed result never reaches the caller.

impact

  • applications following the documented mixed-turn flow repeat side effects: duplicate charges, writes, notifications, tickets
  • with idempotent tools the damage is silent data loss: the second result is simply absent
  • trigger is ordinary documented configuration: tools_to_execute + tools_to_auto_execute, two calls to one tool with valid distinct ids, plus one non-auto call in the same turn

version

  • bifrost dev head 1a17949cd80234de0ceb3018e941854f38b3f7a8, core 1.9.1 (checked 2026-09-21)
  • the same code shape is present in the latest transport release v2.2.1
  • environment: go 1.27.1, darwin arm64, http transport, streamable http mcp
  • not aware of a version where this worked; the summary has been name-keyed since agent mode landed

proposed fix

replace the name-keyed map with one record per tool call id, keeping the tool name with each result:

go
type executedToolResult struct {
	ToolCallID string      `json:"tool_call_id"`
	Name       string      `json:"name"`
	Output     interface{} `json:"output"`
}

toolResults := make([]executedToolResult, 0, len(results))
// ...
toolResults = append(toolResults, executedToolResult{ToolCallID: *callID, Name: toolName, Output: output})

then serialize toolResults into the content summary instead of toolResultsMap, in both the chat and responses adapters. every call stays visible regardless of name collisions, and the summary still matches what the docs already promise: a text content field containing the executed tool results.