Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions core/providers/bedrock/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,35 @@ func (b *BedrockContentBlock) UnmarshalJSON(data []byte) error {
if aux.IsError != nil && *aux.IsError {
b.ToolResult.Status = schemas.Ptr("error")
}
case "server_tool_use":
// Only tool search is carried. Every other Anthropic server tool is either
// Converse-representable or unsupported on this ingress, and reshaping one
// here would be a silent behaviour change well beyond #7155.
if aux.ID == nil || aux.Name == nil || !strings.HasPrefix(*aux.Name, "tool_search_tool_") {
return nil
}
b.AnthropicToolSearchUse = &BedrockAnthropicToolSearchUse{ID: *aux.ID, Name: *aux.Name, Input: aux.Input}
case "tool_search_tool_result":
if aux.ToolUseID == nil {
return nil
}
res := &BedrockAnthropicToolSearchResult{ToolUseID: *aux.ToolUseID}
// Anthropic nests tool_references inside the content object
// ({"type":"tool_search_tool_search_result","tool_references":[...]}); accept the
// flat spelling too, mirroring AnthropicContentBlock.DiscoveredToolReferences.
for _, path := range []string{"content.tool_references", "tool_references"} {
refs := gjson.GetBytes(data, path)
if !refs.Exists() {
continue
}
for _, ref := range refs.Array() {
if name := ref.Get("tool_name"); name.Exists() {
res.ToolReferences = append(res.ToolReferences, name.String())
}
}
break
}
b.AnthropicToolSearchResult = res
case "thinking":
if aux.Thinking == nil {
return nil
Expand Down
100 changes: 100 additions & 0 deletions core/providers/bedrock/invoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1740,3 +1740,103 @@ func TestToBedrockInvokeMessagesStreamResponse_ToolSearchNotToolUse(t *testing.T
assert.Equal(t, int64(2), gjson.GetBytes(bedrockEvent.InvokeModelRawChunks[0], "index").Int())
})
}

// TestToBedrockConverseRequest_InvokeToolSearchReplay covers turn 2 of a tool-search
// conversation on the Bedrock-native invoke ingress. Anthropic requires the client to
// echo the assistant's server_tool_use and tool_search_tool_result back unchanged, but
// BedrockContentBlock.UnmarshalJSON decoded only image/tool_use/tool_result/thinking,
// so both blocks fell through to an empty struct and vanished — leaving the model a
// turn in which it called a tool it never discovered.
func TestToBedrockConverseRequest_InvokeToolSearchReplay(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
const (
searchID = "srvtoolu_01ABC"
callID = "toolu_01XYZ"
found = "get_weather"
)

raw := `{
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 512,
"tools": [
{"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"},
{"name": "` + found + `", "description": "weather", "input_schema": {"type":"object","properties":{}}, "defer_loading": true}
],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "weather in Paris?"}]},
{"role": "assistant", "content": [
{"type": "server_tool_use", "id": "` + searchID + `", "name": "tool_search_tool_regex", "input": {"pattern": "weather"}},
{"type": "tool_search_tool_result", "tool_use_id": "` + searchID + `",
"content": {"type": "tool_search_tool_search_result",
"tool_references": [{"type": "tool_reference", "tool_name": "` + found + `"}]}},
{"type": "tool_use", "id": "` + callID + `", "name": "` + found + `", "input": {"city": "Paris"}}
]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "` + callID + `", "content": "18C"}]}
]
}`

var req BedrockInvokeRequest
require.NoError(t, sonic.Unmarshal([]byte(raw), &req))
req.ModelID = "us.anthropic.claude-sonnet-4-6-v1:0"

converseReq := req.ToBedrockConverseRequest()
bifrostReq, err := converseReq.ToBifrostResponsesRequest(ctx)
require.NoError(t, err)

var search *schemas.ResponsesMessage
var sawDiscoveredCall bool
for i := range bifrostReq.Input {
m := &bifrostReq.Input[i]
if m.Type == nil {
continue
}
switch *m.Type {
case schemas.ResponsesMessageTypeToolSearchCall:
search = m
case schemas.ResponsesMessageTypeFunctionCall:
if m.ResponsesToolMessage != nil && m.ResponsesToolMessage.CallID != nil {
switch *m.ResponsesToolMessage.CallID {
case callID:
sawDiscoveredCall = true
case searchID:
t.Errorf("the srvtoolu_ block replayed as a client function_call")
}
}
}
}

require.NotNil(t, search, "the replayed tool_search pair was dropped: %+v", bifrostReq.Input)
require.NotNil(t, search.ResponsesToolMessage)
require.NotNil(t, search.ResponsesToolMessage.ResponsesToolSearchCall)
assert.Equal(t, []string{found}, search.ResponsesToolMessage.ResponsesToolSearchCall.ToolReferences,
"the discovered tool references must survive replay")
require.NotNil(t, search.ResponsesToolMessage.Name)
assert.Equal(t, "tool_search_tool_regex", *search.ResponsesToolMessage.Name)
// The query the model searched with must survive ingress too: Anthropic requires
// this block to be echoed back unchanged, so a replay that forgets the pattern
// rewrites it on the next turn.
require.NotNil(t, search.ResponsesToolMessage.Arguments,
"server_tool_use.input was dropped at the invoke ingress")
assert.JSONEq(t, `{"pattern":"weather"}`, *search.ResponsesToolMessage.Arguments)
assert.True(t, sawDiscoveredCall, "the tool_use calling the discovered tool must still replay")

// The turn must still route to InvokeModel — tool search never runs on Converse.
assert.True(t, responsesUsesAnthropicInvokePath(ctx, bifrostReq))

// Close the loop: the block this ingress decoded must come back out of the
// InvokeModel serializer with the same input, which is what "echo the assistant's
// content back unchanged" actually requires end to end.
out, err := ToBedrockInvokeMessagesResponse(ctx, &schemas.BifrostResponsesResponse{
ID: schemas.Ptr("msg_replay"),
Model: req.ModelID,
Output: []schemas.ResponsesMessage{*search},
})
require.NoError(t, err)
encoded, err := providerUtils.MarshalSorted(out)
require.NoError(t, err)
roundTripped := gjson.GetBytes(encoded, "content").Array()
require.NotEmpty(t, roundTripped)
assert.Equal(t, "server_tool_use", roundTripped[0].Get("type").String())
assert.Equal(t, "weather", roundTripped[0].Get("input.pattern").String(),
"the replayed block lost its search query on the way back out: %s", string(encoded))
}
45 changes: 45 additions & 0 deletions core/providers/bedrock/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -4469,6 +4469,16 @@ func createTextMessage(
return bifrostMsg
}

// bedrockToolSearchArguments carries a replayed server_tool_use.input onto the neutral
// item's Arguments, so the InvokeModel serializer can echo the block back unchanged.
// An absent input stays nil and the rebuild falls back to {}.
func bedrockToolSearchArguments(input json.RawMessage) *string {
if len(input) == 0 {
return nil
}
return schemas.Ptr(string(input))
}

// convertSingleBedrockMessageToBifrostMessages converts a single Bedrock message to Bifrost messages
func convertSingleBedrockMessageToBifrostMessages(ctx *schemas.BifrostContext, msg *BedrockMessage, isOutputMessage bool) []schemas.ResponsesMessage {
var outputMessages []schemas.ResponsesMessage
Expand Down Expand Up @@ -4518,6 +4528,15 @@ func convertSingleBedrockMessageToBifrostMessages(ctx *schemas.BifrostContext, m
}
}

// Pre-scan: pair replayed tool_search_tool_result blocks to the server_tool_use they
// answer, so the tool_search_call item is emitted complete when the use block is hit.
toolSearchResults := make(map[string][]string)
for i := range msg.Content {
if r := msg.Content[i].AnthropicToolSearchResult; r != nil {
toolSearchResults[r.ToolUseID] = r.ToolReferences
}
}

// lastTextOutputIdx tracks the index into outputMessages of the most recently appended
// text message, so standalone citationsContent blocks can be attached to it as annotations.
lastTextOutputIdx := -1
Expand All @@ -4532,6 +4551,32 @@ func convertSingleBedrockMessageToBifrostMessages(ctx *schemas.BifrostContext, m
continue
}

// A replayed tool_search_tool_result is consumed by the pre-scan above; its
// references are attached to the matching server_tool_use block.
if block.AnthropicToolSearchResult != nil {
continue
}
if block.AnthropicToolSearchUse != nil {
// Rebuild the neutral tool_search_call so the pair survives the turn and the
// egress converter can re-emit both blocks verbatim. Without this the replayed
// search is dropped and the model is shown a turn where it called a tool it
// never discovered.
outputMessages = append(outputMessages, schemas.ResponsesMessage{
ID: schemas.Ptr(block.AnthropicToolSearchUse.ID),
Type: schemas.Ptr(schemas.ResponsesMessageTypeToolSearchCall),
Status: schemas.Ptr("completed"),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr(block.AnthropicToolSearchUse.ID),
Name: schemas.Ptr(block.AnthropicToolSearchUse.Name),
Arguments: bedrockToolSearchArguments(block.AnthropicToolSearchUse.Input),
ResponsesToolSearchCall: &schemas.ResponsesToolSearchCall{
ToolReferences: toolSearchResults[block.AnthropicToolSearchUse.ID],
},
},
})
continue
}

if block.Text != nil {
// Text content
role := convertBedrockRoleToBifrostRole(msg.Role)
Expand Down
23 changes: 23 additions & 0 deletions core/providers/bedrock/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,29 @@ type BedrockContentBlock struct {

// Citations from nova_grounding — co-located with a text block in the same content block
CitationsContent *BedrockCitationsContent `json:"citationsContent,omitempty"`

// Replayed Anthropic tool-search blocks. A client must echo the assistant's
// server_tool_use and tool_search_tool_result back unchanged on the next turn,
// but Converse has no wire slot for either — so they ride across the invoke
// ingress on these json:"-" carriers (#7155).
AnthropicToolSearchUse *BedrockAnthropicToolSearchUse `json:"-"`
AnthropicToolSearchResult *BedrockAnthropicToolSearchResult `json:"-"`
}

// BedrockAnthropicToolSearchUse is a replayed server_tool_use naming a tool-search
// variant. Input is the query the model searched with; Anthropic requires the client to
// echo this block back unchanged, so dropping it rewrites the block on the next turn.
type BedrockAnthropicToolSearchUse struct {
ID string
Name string
Input json.RawMessage
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// BedrockAnthropicToolSearchResult is a replayed tool_search_tool_result: the id of the
// server_tool_use it answers, plus the names of the tools that search discovered.
type BedrockAnthropicToolSearchResult struct {
ToolUseID string
ToolReferences []string
}

type BedrockCachePointType string
Expand Down
Loading