Skip to content
Open
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
80 changes: 80 additions & 0 deletions provider/anthropicprovider/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package anthropicprovider
import (
"cmp"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"iter"
Expand All @@ -20,6 +21,7 @@ import (
"github.com/microsoft/agent-framework-go/agent/harness/toolautocall"
"github.com/microsoft/agent-framework-go/message"
"github.com/microsoft/agent-framework-go/tool"
"github.com/microsoft/agent-framework-go/tool/hostedtool"
)

type messageNewParamsOpt anthropic.MessageNewParams
Expand Down Expand Up @@ -295,10 +297,80 @@ func (a *client) buildBlock(index int, v any, contents []message.Content, functi
Name: v.Name,
Arguments: string(v.Input),
}
case anthropic.ServerToolUseBlock:
if v.Name == anthropic.ServerToolUseBlockNameCodeExecution {
call := &message.CodeInterpreterToolCallContent{
CallID: v.ID,
ContentHeader: message.ContentHeader{
RawRepresentation: v,
},
}
if code := codeExecutionInput(v.Input); code != "" {
call.Inputs = message.Contents{
&message.DataContent{
Data: base64.StdEncoding.EncodeToString([]byte(code)),
MediaType: "text/x-python",
},
}
}
contents = append(contents, call)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity note: code_execution tool-call input encoding differs from Python

The Python implementation (_chat_client.py, line 1252–1265) wraps the raw input block as Content.from_text(text=str(content_block.input)) — a plain TextContent containing the string representation of the input dict.

This Go implementation extracts the "code" key from the JSON payload and stores it base64-encoded as DataContent with media_type: text/x-python. While arguably more structured (and analogous to how OpenAI Responses surfaces it), this is a deliberate cross-SDK divergence: a consumer inspecting CodeInterpreterToolCallContent.Inputs[0] will receive a DataContent in Go but a TextContent in Python.

If this encoding difference is intentional, please document it (e.g., in a CHANGELOG entry or an inline comment noting the divergence from Python). If it should align, switch to &message.TextContent{Text: code} or align the Python side to emit DataContent.

}
case anthropic.CodeExecutionToolResultBlock:
result := &message.CodeInterpreterToolResultContent{
CallID: v.ToolUseID,
ContentHeader: message.ContentHeader{
RawRepresentation: v,
},
}
res := v.Content
if res.Stdout != "" {
result.Outputs = append(result.Outputs, &message.TextContent{
Text: res.Stdout,
ContentHeader: message.ContentHeader{RawRepresentation: res},
})
}
if res.Stderr != "" {
result.Outputs = append(result.Outputs, &message.TextContent{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue: stderr mapped to TextContent instead of ErrorContent

The upstream Python implementation (agent_framework_anthropic/_chat_client.py, case "code_execution_tool_result") maps stderr to Content.from_error(message=content_block.content.stderr), which produces an ErrorContent node. Here the Go implementation maps stderr to &message.TextContent{}. Callers that switch on content type (e.g., to distinguish diagnostic output from normal output) will behave differently across SDKs.

Suggestion: use &message.ErrorContent{Message: res.Stderr, ...} for stderr to match Python semantics. Similarly, error_code (line 339) maps to TextContent in Go but to Content.from_error() in Python — both should use ErrorContent.

Upstream reference: python/packages/anthropic/agent_framework_anthropic/_chat_client.py lines 1338–1356.

Text: res.Stderr,
ContentHeader: message.ContentHeader{RawRepresentation: res},
})
}
if res.ErrorCode != "" {
result.Outputs = append(result.Outputs, &message.TextContent{
Text: string(res.ErrorCode),
ContentHeader: message.ContentHeader{RawRepresentation: res},
})
}
for _, out := range res.Content {
result.Outputs = append(result.Outputs, &message.HostedFileContent{
FileID: out.FileID,
ContentHeader: message.ContentHeader{RawRepresentation: out},
})
}
contents = append(contents, result)
}
return contents
}

// codeExecutionInput extracts the source code from an Anthropic server_tool_use
// code_execution input payload (shaped as {"code": "..."}).
func codeExecutionInput(input any) string {
if input == nil {
return ""
}
raw, err := json.Marshal(input)
if err != nil {
return ""
}
var parsed struct {
Code string `json:"code"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return ""
}
return parsed.Code
}

// citationAnnotations converts Anthropic text-block citations into
// [message.CitationAnnotation] values. It returns nil when there are no
// citations so callers can leave the annotations slice unset.
Expand Down Expand Up @@ -398,6 +470,14 @@ func (a *client) buildMessageParams(messages []*message.Message, opts []agent.Op
toolParam.OfTool.Description = anthropic.String(description)
}
tools = append(tools, toolParam)
continue
}
if _, ok := tl.(*hostedtool.CodeInterpreter); ok {
// Map the hosted code-interpreter marker onto Anthropic's server-side
// code_execution tool, mirroring the OpenAI Responses provider.
tools = append(tools, anthropic.ToolUnionParam{
OfCodeExecutionTool20250825: &anthropic.CodeExecutionTool20250825Param{},
})
}
}
if len(tools) > 0 {
Expand Down
157 changes: 157 additions & 0 deletions provider/anthropicprovider/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package anthropicprovider_test

import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand All @@ -18,6 +19,7 @@ import (
"github.com/microsoft/agent-framework-go/agent"
"github.com/microsoft/agent-framework-go/message"
"github.com/microsoft/agent-framework-go/provider/anthropicprovider"
"github.com/microsoft/agent-framework-go/tool/hostedtool"
)

// testOutput is the structured type used across structured output tests.
Expand Down Expand Up @@ -846,6 +848,161 @@ func TestToolUseEmptyArgumentsSerializeAsObject(t *testing.T) {
}
}

// TestCodeInterpreterToolMapsToCodeExecution verifies that passing the hosted
// *hostedtool.CodeInterpreter marker causes the provider to include Anthropic's
// server-side code_execution tool in the request, mirroring the OpenAI Responses
// provider's code_interpreter mapping.
func TestCodeInterpreterToolMapsToCodeExecution(t *testing.T) {
bodyCh := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request body: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
bodyCh <- body
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, minimalMessageResponse("ok"))
}))
defer server.Close()

a := newTestClient(t, server)
if _, err := a.RunText(t.Context(), "run some code",
agent.WithTool(&hostedtool.CodeInterpreter{}),
).Collect(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

var req map[string]any
if err := json.Unmarshal(<-bodyCh, &req); err != nil {
t.Fatalf("unmarshal request body: %v", err)
}
tools, ok := req["tools"].([]any)
if !ok {
t.Fatalf("request tools = %#v, want a JSON array", req["tools"])
}
found := false
for _, tl := range tools {
tool, ok := tl.(map[string]any)
if !ok {
continue
}
if tool["type"] == "code_execution_20250825" && tool["name"] == "code_execution" {
found = true
}
}
if !found {
t.Fatalf("code_execution tool not found in request tools: %#v", tools)
}
}

// TestCodeInterpreterResultBlocksBecomeStructuredContent verifies that Anthropic
// server_tool_use (code_execution) and code_execution_tool_result response blocks
// are surfaced as structured message.CodeInterpreterToolCallContent /
// CodeInterpreterToolResultContent, mirroring the OpenAI Responses mapping.
func TestCodeInterpreterResultBlocksBecomeStructuredContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{
"id":"msg_code_exec",
"type":"message",
"role":"assistant",
"model":"claude-3-5-sonnet-20241022",
"stop_reason":"end_turn",
"stop_sequence":null,
"content":[
{
"type":"server_tool_use",
"id":"srvtoolu_1",
"name":"code_execution",
"input":{"code":"print(sum(range(1, 6)))"}
},
{
"type":"code_execution_tool_result",
"tool_use_id":"srvtoolu_1",
"content":{
"type":"code_execution_result",
"stdout":"15\n",
"stderr":"",
"return_code":0,
"content":[{"type":"code_execution_output","file_id":"file_abc"}]
}
},
{"type":"text","text":"The sum is 15."}
],
"usage":{"input_tokens":10,"output_tokens":5}
}`)
}))
defer server.Close()

a := newTestClient(t, server)
resp, err := a.RunText(t.Context(), "run some code",
agent.WithTool(&hostedtool.CodeInterpreter{}),
).Collect()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var call *message.CodeInterpreterToolCallContent
var result *message.CodeInterpreterToolResultContent
for content := range resp.Contents() {
switch c := content.(type) {
case *message.CodeInterpreterToolCallContent:
call = c
case *message.CodeInterpreterToolResultContent:
result = c
}
}

if call == nil {
t.Fatal("expected a CodeInterpreterToolCallContent")
}
if call.CallID != "srvtoolu_1" {
t.Errorf("call CallID = %q, want %q", call.CallID, "srvtoolu_1")
}
if len(call.Inputs) != 1 {
t.Fatalf("call Inputs length = %d, want 1", len(call.Inputs))
}
data, ok := call.Inputs[0].(*message.DataContent)
if !ok {
t.Fatalf("call input type = %T, want *message.DataContent", call.Inputs[0])
}
if data.MediaType != "text/x-python" {
t.Errorf("call input MediaType = %q, want %q", data.MediaType, "text/x-python")
}
decoded, err := base64.StdEncoding.DecodeString(data.Data)
if err != nil {
t.Fatalf("decode call input data: %v", err)
}
if string(decoded) != "print(sum(range(1, 6)))" {
t.Errorf("call input code = %q, want %q", string(decoded), "print(sum(range(1, 6)))")
}

if result == nil {
t.Fatal("expected a CodeInterpreterToolResultContent")
}
if result.CallID != "srvtoolu_1" {
t.Errorf("result CallID = %q, want %q", result.CallID, "srvtoolu_1")
}
var stdout *message.TextContent
var file *message.HostedFileContent
for _, out := range result.Outputs {
switch o := out.(type) {
case *message.TextContent:
stdout = o
case *message.HostedFileContent:
file = o
}
}
if stdout == nil || stdout.Text != "15\n" {
t.Errorf("result stdout = %#v, want text %q", stdout, "15\n")
}
if file == nil || file.FileID != "file_abc" {
t.Errorf("result file = %#v, want FileID %q", file, "file_abc")
}
}

// findToolResultBlock returns the tool_result block for the given call ID from
// an Anthropic messages request body, or nil if not present.
func findToolResultBlock(t *testing.T, body []byte, callID string) map[string]any {
Expand Down
Loading