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
52 changes: 44 additions & 8 deletions providers/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,9 @@ func (a languageModel) prepareParams(call fantasy.Call) (
sendReasoning = *providerOptions.SendReasoning
}
systemBlocks, messages, warnings := toPrompt(call.Prompt, sendReasoning)
if declaresComputerToolset(call.Tools) {
warnings = append(warnings, replayComputerToolsetCalls(messages)...)
}

if call.FrequencyPenalty != nil {
warnings = append(warnings, fantasy.CallWarning{
Expand Down Expand Up @@ -1081,7 +1084,9 @@ func (a languageModel) toTools(tools []fantasy.Tool, toolChoice *fantasy.ToolCho
})
continue
}
betaFlags = append(betaFlags, flag)
if flag != "" {
betaFlags = append(betaFlags, flag)
}
}
rawTools = append(rawTools, raw)
continue
Expand Down Expand Up @@ -1657,10 +1662,15 @@ func (a languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas
if !ok {
continue
}
toolName, input := toolUse.Name, string(toolUse.Input)
if isComputerToolsetMember(toolUse) {
toolName = computerUseAPIName
input = computerToolsetCallInput(toolUse.Name, input)
}
content = append(content, fantasy.ToolCallContent{
ToolCallID: toolUse.ID,
ToolName: toolUse.Name,
Input: string(toolUse.Input),
ToolName: toolName,
Input: input,
ProviderExecuted: false,
})
case "server_tool_use":
Expand Down Expand Up @@ -1786,12 +1796,19 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
}
case "content_block_start":
contentBlockType := chunk.ContentBlock.Type
toolName := chunk.ContentBlock.Name
if contentBlockType == "tool_use" || contentBlockType == "server_tool_use" {
openToolBlocks[chunk.Index] = &openToolBlock{
open := &openToolBlock{
id: chunk.ContentBlock.ID,
name: chunk.ContentBlock.Name,
name: toolName,
providerExecuted: contentBlockType == "server_tool_use",
}
if contentBlockType == "tool_use" && isComputerToolsetMember(chunk.ContentBlock.AsToolUse()) {
open.toolsetMember = toolName
open.name = computerUseAPIName
toolName = computerUseAPIName
}
openToolBlocks[chunk.Index] = open
}
switch contentBlockType {
case "text":
Expand Down Expand Up @@ -1820,7 +1837,7 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeToolInputStart,
ID: chunk.ContentBlock.ID,
ToolCallName: chunk.ContentBlock.Name,
ToolCallName: toolName,
ToolCallInput: "",
}) {
return
Expand Down Expand Up @@ -1969,6 +1986,11 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
continue
}
open.input.WriteString(chunk.Delta.PartialJSON)
if open.toolsetMember != "" {
// The member input lacks the action field, so it
// is sent once, merged, when the block closes.
continue
}
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeToolInputDelta,
ID: open.id,
Expand Down Expand Up @@ -2066,12 +2088,26 @@ type openToolBlock struct {
id string
name string
providerExecuted bool
input strings.Builder
// toolsetMember is the member name of a computer use toolset call,
// which is reported as the computer tool with an action input.
toolsetMember string
input strings.Builder
}

// close emits the end-of-input and tool call parts for the block. It reports
// whether the consumer wants more parts.
func (o *openToolBlock) close(acc anthropic.Message, index int64, yield func(fantasy.StreamPart) bool) bool {
arguments := o.arguments(acc, index)
if o.toolsetMember != "" {
arguments = computerToolsetCallInput(o.toolsetMember, arguments)
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeToolInputDelta,
ID: o.id,
ToolCallInput: arguments,
}) {
return false
}
}
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeToolInputEnd,
ID: o.id,
Expand All @@ -2083,7 +2119,7 @@ func (o *openToolBlock) close(acc anthropic.Message, index int64, yield func(fan
Type: fantasy.StreamPartTypeToolCall,
ID: o.id,
ToolCallName: o.name,
ToolCallInput: o.arguments(acc, index),
ToolCallInput: arguments,
ProviderExecuted: o.providerExecuted,
})
}
Expand Down
123 changes: 123 additions & 0 deletions providers/anthropic/computer_toolset.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package anthropic

import (
"encoding/json"
"fmt"
"maps"
"strings"

"charm.land/fantasy"
"github.com/charmbracelet/anthropic-sdk-go"
)

// computerToolsetName is the toolset_name that marks tool_use and
// tool_result blocks as members of the computer use toolset.
const computerToolsetName = "computer"

// computerToolsetReplayFallbackMember is the member a replayed
// computer call is sent as when its input has no usable action.
const computerToolsetReplayFallbackMember = string(ActionScreenshot)

// isComputerToolsetMember reports whether a tool_use block is a
// computer use toolset member call. The SDK has no typed
// toolset_name field, so it is read from the block's extra fields.
func isComputerToolsetMember(block anthropic.ToolUseBlock) bool {
return block.JSON.ExtraFields["toolset_name"].Raw() == `"`+computerToolsetName+`"`
}

// computerToolsetCallInput returns a member call's input in the
// legacy computer tool shape: the member input object with an
// "action" field naming the member. Input that is not a JSON object,
// such as arguments truncated at max_tokens, is returned unchanged
// so the call is still reported as invalid instead of being guessed.
func computerToolsetCallInput(member, input string) string {
fields := map[string]any{}
if strings.TrimSpace(input) != "" {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(input), &raw); err != nil {
return input
}
for k, v := range raw {
fields[k] = v
}
}
fields["action"] = member
merged, err := json.Marshal(fields)
if err != nil {
return input
}
return string(merged)
}

// declaresComputerToolset reports whether tools include the computer
// use tool at version ComputerUseToolset20260801.
func declaresComputerToolset(tools []fantasy.Tool) bool {
for _, tool := range tools {
pdt, ok := asProviderDefinedTool(tool)
if !ok || pdt.ID != computerUseToolID {
continue
}
if version, ok := getComputerUseVersion(pdt); ok && version == ComputerUseToolset20260801 {
return true
}
}
return false
}

// replayComputerToolsetCalls rewrites replayed computer calls from
// the legacy shape (name "computer", input with "action") into
// toolset member calls, and tags every tool_result answering one
// with toolset_name. The API rejects a member call or result that
// lacks toolset_name. It edits messages in place.
func replayComputerToolsetCalls(messages []anthropic.MessageParam) []fantasy.CallWarning {
var warnings []fantasy.CallWarning
memberCalls := map[string]struct{}{}
for _, msg := range messages {
for _, block := range msg.Content {
switch {
case block.OfToolUse != nil && block.OfToolUse.Name == computerUseAPIName:
toolUse := block.OfToolUse
member, input, ok := computerToolsetMemberCall(toolUse.Input)
if !ok {
// Every tool_result must answer a tool_use with the
// same ID, so the call cannot be dropped. screenshot
// takes no input, so it is a valid member call for
// any history; the result keeps its pairing.
member, input = computerToolsetReplayFallbackMember, map[string]any{}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
Message: fmt.Sprintf(
"computer tool call %q has no string action; replaying it as the %s toolset member to preserve tool_use and tool_result pairing",
toolUse.ID, computerToolsetReplayFallbackMember,
),
})
}
toolUse.Name = member
toolUse.Input = input
toolUse.SetExtraFields(map[string]any{"toolset_name": computerToolsetName})
memberCalls[toolUse.ID] = struct{}{}
case block.OfToolResult != nil:
if _, ok := memberCalls[block.OfToolResult.ToolUseID]; ok {
block.OfToolResult.SetExtraFields(map[string]any{"toolset_name": computerToolsetName})
}
}
}
}
return warnings
}

// computerToolsetMemberCall splits a legacy computer call input into
// the member name (its action) and the member input (the rest).
func computerToolsetMemberCall(input any) (member string, memberInput map[string]any, ok bool) {
fields, ok := input.(map[string]any)
if !ok {
return "", nil, false
}
action, ok := fields["action"].(string)
if !ok || action == "" {
return "", nil, false
}
memberInput = maps.Clone(fields)
delete(memberInput, "action")
return action, memberInput, true
}
Loading
Loading