fix(openai): canonicalize issued tool calls

This commit is contained in:
toki 2026-08-15 06:39:36 +09:00
parent 51e5e4f35b
commit fe08764fcf
4 changed files with 52 additions and 14 deletions

View file

@ -739,6 +739,17 @@ func (s *artifactFrontierStore) matchRecordLocked(
return record, true, nil
}
}
for _, record := range candidates {
if record.ownerEdgeID != ownerEdgeID || record.principalRef != principalRef || record.protocol != protocol {
continue
}
if record.lineage != lineage.Prefix {
return nil, true, describeArtifactPrefixMismatch(record.lineage, lineage.Prefix)
}
if record.pendingHash != lineage.IssuedCallHash {
return nil, true, fmt.Errorf("%w: issued tool calls changed", errLogicalRequestLineage)
}
}
return nil, true, errLogicalRequestLineage
}

View file

@ -389,7 +389,7 @@ func TestIssuedToolHashIgnoresSDKAssistantDecoration(t *testing.T) {
"content":null,
"reasoning_content":"sdk-normalized",
"provider_metadata":{"ignored":true},
"tool_calls":[{"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"printf ok\"}"}}]
"tool_calls":[{"id":"call_1","type":"function","provider_metadata":{"ignored":true},"function":{"name":"bash","arguments":"{ \"command\" : \"printf ok\" }"}}]
}`))
if err != nil {
t.Fatal(err)

View file

@ -106,7 +106,7 @@ func TestPresetRequestIdentityAcrossChatTurns(t *testing.T) {
// Simulate stage 1 assistant issuing tool call "call_c1"
assistantMsg := json.RawMessage(`{"role":"assistant","tool_calls":[{"id":"call_c1","type":"function","function":{"name":"search"}}]}`)
issuedHash, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, assistantMsg)
issuedHash, err := chatIssuedCallHash(assistantMsg)
if err != nil {
t.Fatalf("fingerprintCanonicalJSON: %v", err)
}
@ -249,7 +249,7 @@ func TestPresetRequestIdentityAcrossAnthropicTurns(t *testing.T) {
// Simulate assistant issuing tool_use block tu_a1
assistantMsg := json.RawMessage(`{"role":"assistant","content":[{"type":"tool_use","id":"tu_a1","name":"search","input":{}}]}`)
issuedHash, err := fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, assistantMsg)
issuedHash, err := anthropicIssuedCallHash(assistantMsg)
if err != nil {
t.Fatalf("fingerprintCanonicalJSON: %v", err)
}
@ -362,7 +362,7 @@ func TestPresetRequestIdentityRejectionCases(t *testing.T) {
coord.mu.Unlock()
assistantMsg := json.RawMessage(`{"role":"assistant","tool_calls":[{"id":"call_r1","type":"function","function":{"name":"search"}}]}`)
issuedHash, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, assistantMsg)
issuedHash, err := chatIssuedCallHash(assistantMsg)
if err != nil {
t.Fatalf("fingerprintCanonicalJSON: %v", err)
}

View file

@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
)
// logicalRequestEndpoint keeps fingerprints from incompatible wire formats
@ -575,14 +576,36 @@ func newAnthropicContinuationLineage(raw json.RawMessage) (logicalRequestContinu
func chatIssuedCallHash(raw json.RawMessage) (string, error) {
var message struct {
Role string `json:"role"`
ToolCalls []json.RawMessage `json:"tool_calls"`
Role string `json:"role"`
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
}
if err := json.Unmarshal(raw, &message); err != nil {
return "", err
}
semanticCalls := make([]map[string]any, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
var arguments any = map[string]any{}
if strings.TrimSpace(call.Function.Arguments) != "" {
decoder := json.NewDecoder(bytes.NewBufferString(call.Function.Arguments))
decoder.UseNumber()
if err := decoder.Decode(&arguments); err != nil {
return "", fmt.Errorf("decode issued tool arguments: %w", err)
}
}
semanticCalls = append(semanticCalls, map[string]any{
"id": call.ID, "type": call.Type,
"function": map[string]any{"name": call.Function.Name, "arguments": arguments},
})
}
return fingerprintCanonicalJSON(logicalRequestEndpointChat, map[string]any{
"role": message.Role, "tool_calls": message.ToolCalls,
"role": message.Role, "tool_calls": semanticCalls,
})
}
@ -594,16 +617,20 @@ func anthropicIssuedCallHash(raw json.RawMessage) (string, error) {
if err := json.Unmarshal(raw, &message); err != nil {
return "", err
}
toolUses := make([]json.RawMessage, 0, len(message.Content))
type semanticToolUse struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
}
toolUses := make([]semanticToolUse, 0, len(message.Content))
for _, block := range message.Content {
var header struct {
Type string `json:"type"`
}
if err := json.Unmarshal(block, &header); err != nil {
var toolUse semanticToolUse
if err := json.Unmarshal(block, &toolUse); err != nil {
return "", err
}
if header.Type == "tool_use" {
toolUses = append(toolUses, block)
if toolUse.Type == "tool_use" {
toolUses = append(toolUses, toolUse)
}
}
return fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, map[string]any{