Detailed changes
@@ -141,9 +141,9 @@ type agentSettings struct {
userAgent string
providerOptions ProviderOptions
- // TODO: add support for provider tools
- tools []AgentTool
- maxRetries *int
+ providerDefinedTools []ProviderDefinedTool
+ tools []AgentTool
+ maxRetries *int
model LanguageModel
@@ -429,7 +429,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
}
}
- preparedTools := a.prepareTools(stepTools, stepActiveTools, disableAllTools)
+ preparedTools := a.prepareTools(stepTools, a.settings.providerDefinedTools, stepActiveTools, disableAllTools)
retryOptions := DefaultRetryOptions()
if opts.MaxRetries != nil {
@@ -464,7 +464,12 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
if !ok {
continue
}
-
+ // Provider-executed tool calls (e.g. web search) are
+ // handled by the provider and should not be validated
+ // or executed by the agent.
+ if toolCall.ProviderExecuted {
+ continue
+ }
// Validate and potentially repair the tool call
validatedToolCall := a.validateAndRepairToolCall(ctx, toolCall, stepTools, stepSystemPrompt, stepInputMessages, a.settings.repairToolCall)
stepToolCalls = append(stepToolCalls, validatedToolCall)
@@ -473,22 +478,26 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
toolResults, err := a.executeTools(ctx, stepTools, stepToolCalls, nil)
- // Build step content with validated tool calls and tool results
+ // Build step content with validated tool calls and tool results.
+ // Provider-executed tool calls are kept as-is.
stepContent := []Content{}
toolCallIndex := 0
for _, content := range result.Content {
if content.GetType() == ContentTypeToolCall {
- // Replace with validated tool call
+ tc, ok := AsContentType[ToolCallContent](content)
+ if ok && tc.ProviderExecuted {
+ stepContent = append(stepContent, content)
+ continue
+ }
+ // Replace with validated tool call.
if toolCallIndex < len(stepToolCalls) {
stepContent = append(stepContent, stepToolCalls[toolCallIndex])
toolCallIndex++
}
} else {
- // Keep other content as-is
stepContent = append(stepContent, content)
}
- }
- // Add tool results
+ } // Add tool results
for _, result := range toolResults {
stepContent = append(stepContent, result)
}
@@ -601,11 +610,20 @@ func toResponseMessages(content []Content) []Message {
if !ok {
continue
}
- toolParts = append(toolParts, ToolResultPart{
- ToolCallID: result.ToolCallID,
- Output: result.Result,
- ProviderOptions: ProviderOptions(result.ProviderMetadata),
- })
+ resultPart := ToolResultPart{
+ ToolCallID: result.ToolCallID,
+ Output: result.Result,
+ ProviderExecuted: result.ProviderExecuted,
+ ProviderOptions: ProviderOptions(result.ProviderMetadata),
+ }
+ if result.ProviderExecuted {
+ // Provider-executed tool results (e.g. web search)
+ // belong in the assistant message alongside the
+ // server_tool_use block that produced them.
+ assistantParts = append(assistantParts, resultPart)
+ } else {
+ toolParts = append(toolParts, resultPart)
+ }
}
}
@@ -813,7 +831,7 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
}
}
- preparedTools := a.prepareTools(stepTools, stepActiveTools, disableAllTools)
+ preparedTools := a.prepareTools(stepTools, a.settings.providerDefinedTools, stepActiveTools, disableAllTools)
// Start step stream
if opts.OnStepStart != nil {
@@ -902,7 +920,7 @@ func (a *agent) Stream(ctx context.Context, opts AgentStreamCall) (*AgentResult,
return agentResult, nil
}
-func (a *agent) prepareTools(tools []AgentTool, activeTools []string, disableAllTools bool) []Tool {
+func (a *agent) prepareTools(tools []AgentTool, providerDefinedTools []ProviderDefinedTool, activeTools []string, disableAllTools bool) []Tool {
preparedTools := make([]Tool, 0, len(tools))
// If explicitly disabling all tools, return no tools
@@ -930,6 +948,9 @@ func (a *agent) prepareTools(tools []AgentTool, activeTools []string, disableAll
ProviderOptions: tool.ProviderOptions(),
})
}
+ for _, tool := range providerDefinedTools {
+ preparedTools = append(preparedTools, tool)
+ }
return preparedTools
}
@@ -1063,6 +1084,15 @@ func WithTools(tools ...AgentTool) AgentOption {
}
}
+// WithProviderDefinedTools sets the provider-defined tools for the agent.
+// These tools are executed by the provider (e.g. web search) rather
+// than by the client.
+func WithProviderDefinedTools(tools ...ProviderDefinedTool) AgentOption {
+ return func(s *agentSettings) {
+ s.providerDefinedTools = append(s.providerDefinedTools, tools...)
+ }
+}
+
// WithStopConditions sets the stop conditions for the agent.
func WithStopConditions(conditions ...StopCondition) AgentOption {
return func(s *agentSettings) {
@@ -1311,29 +1341,62 @@ func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, op
ProviderMetadata: part.ProviderMetadata,
}
- // Validate and potentially repair the tool call
- validatedToolCall := a.validateAndRepairToolCall(ctx, toolCall, stepTools, a.settings.systemPrompt, nil, opts.RepairToolCall)
- stepToolCalls = append(stepToolCalls, validatedToolCall)
- stepContent = append(stepContent, validatedToolCall)
+ // Provider-executed tool calls are handled by the provider
+ // and should not be validated or executed by the agent.
+ if toolCall.ProviderExecuted {
+ stepContent = append(stepContent, toolCall)
+ if opts.OnToolCall != nil {
+ err := opts.OnToolCall(toolCall)
+ if err != nil {
+ return stepExecutionResult{}, err
+ }
+ }
+ delete(activeToolCalls, part.ID)
+ } else {
+ // Validate and potentially repair the tool call
+ validatedToolCall := a.validateAndRepairToolCall(ctx, toolCall, stepTools, a.settings.systemPrompt, nil, opts.RepairToolCall)
+ stepToolCalls = append(stepToolCalls, validatedToolCall)
+ stepContent = append(stepContent, validatedToolCall)
- if opts.OnToolCall != nil {
- err := opts.OnToolCall(validatedToolCall)
- if err != nil {
- return stepExecutionResult{}, err
+ if opts.OnToolCall != nil {
+ err := opts.OnToolCall(validatedToolCall)
+ if err != nil {
+ return stepExecutionResult{}, err
+ }
}
- }
- // Determine if tool can run in parallel
- isParallel := false
- if tool, exists := toolMap[validatedToolCall.ToolName]; exists {
- isParallel = tool.Info().Parallel
- }
+ // Determine if tool can run in parallel
+ isParallel := false
+ if tool, exists := toolMap[validatedToolCall.ToolName]; exists {
+ isParallel = tool.Info().Parallel
+ }
- // Send tool call to execution channel
- toolChan <- toolExecutionRequest{toolCall: validatedToolCall, parallel: isParallel}
+ // Send tool call to execution channel
+ toolChan <- toolExecutionRequest{toolCall: validatedToolCall, parallel: isParallel}
- // Clean up active tool call
- delete(activeToolCalls, part.ID)
+ // Clean up active tool call
+ delete(activeToolCalls, part.ID)
+ }
+
+ case StreamPartTypeToolResult:
+ // Provider-executed tool results (e.g. web search)
+ // are emitted by the provider and added directly
+ // to the step content for multi-turn round-tripping.
+ if part.ProviderExecuted {
+ resultContent := ToolResultContent{
+ ToolCallID: part.ID,
+ ToolName: part.ToolCallName,
+ ProviderExecuted: true,
+ ProviderMetadata: part.ProviderMetadata,
+ }
+ stepContent = append(stepContent, resultContent)
+ if opts.OnToolResult != nil {
+ err := opts.OnToolResult(resultContent)
+ if err != nil {
+ return stepExecutionResult{}, err
+ }
+ }
+ }
case StreamPartTypeSource:
sourceContent := SourceContent{
@@ -1768,3 +1768,84 @@ func TestAgent_MediaToolResponses(t *testing.T) {
require.Equal(t, 600, metadata.Height)
})
}
+
+func TestToResponseMessages_ProviderExecutedRouting(t *testing.T) {
+ t.Parallel()
+
+ // Build step content that mixes a provider-executed tool call/result
+ // (e.g. web search) with a regular local tool call/result.
+ content := []Content{
+ // Provider-executed tool call.
+ &ToolCallContent{
+ ToolCallID: "srvtoolu_01",
+ ToolName: "web_search",
+ Input: `{"query":"test"}`,
+ ProviderExecuted: true,
+ },
+ // Provider-executed tool result.
+ &ToolResultContent{
+ ToolCallID: "srvtoolu_01",
+ ProviderExecuted: true,
+ },
+ // Regular (locally-executed) tool call.
+ &ToolCallContent{
+ ToolCallID: "toolu_02",
+ ToolName: "calculator",
+ Input: `{"expr":"1+1"}`,
+ },
+ // Regular tool result.
+ &ToolResultContent{
+ ToolCallID: "toolu_02",
+ Result: ToolResultOutputContentText{Text: "2"},
+ },
+ // Some trailing text.
+ &TextContent{Text: "Done."},
+ }
+
+ msgs := toResponseMessages(content)
+
+ // Expect two messages: assistant + tool.
+ require.Len(t, msgs, 2)
+
+ // Assistant message should contain:
+ // 1. provider-executed ToolCallPart
+ // 2. provider-executed ToolResultPart
+ // 3. regular ToolCallPart
+ // 4. TextPart
+ assistant := msgs[0]
+ require.Equal(t, MessageRoleAssistant, assistant.Role)
+ require.Len(t, assistant.Content, 4)
+
+ // Verify provider-executed tool call is in assistant.
+ tc1, ok := AsMessagePart[ToolCallPart](assistant.Content[0])
+ require.True(t, ok)
+ require.Equal(t, "srvtoolu_01", tc1.ToolCallID)
+ require.True(t, tc1.ProviderExecuted)
+
+ // Verify provider-executed tool result is in assistant.
+ tr1, ok := AsMessagePart[ToolResultPart](assistant.Content[1])
+ require.True(t, ok)
+ require.Equal(t, "srvtoolu_01", tr1.ToolCallID)
+ require.True(t, tr1.ProviderExecuted)
+
+ // Verify regular tool call is in assistant.
+ tc2, ok := AsMessagePart[ToolCallPart](assistant.Content[2])
+ require.True(t, ok)
+ require.Equal(t, "toolu_02", tc2.ToolCallID)
+ require.False(t, tc2.ProviderExecuted)
+
+ // Verify text part is in assistant.
+ text, ok := AsMessagePart[TextPart](assistant.Content[3])
+ require.True(t, ok)
+ require.Equal(t, "Done.", text.Text)
+
+ // Tool message should contain only the regular tool result.
+ toolMsg := msgs[1]
+ require.Equal(t, MessageRoleTool, toolMsg.Role)
+ require.Len(t, toolMsg.Content, 1)
+
+ tr2, ok := AsMessagePart[ToolResultPart](toolMsg.Content[0])
+ require.True(t, ok)
+ require.Equal(t, "toolu_02", tr2.ToolCallID)
+ require.False(t, tr2.ProviderExecuted)
+}
@@ -252,9 +252,10 @@ func (t ToolCallPart) Options() ProviderOptions {
// ToolResultPart represents a tool result in a message.
type ToolResultPart struct {
- ToolCallID string `json:"tool_call_id"`
- Output ToolResultOutputContent `json:"output"`
- ProviderOptions ProviderOptions `json:"provider_options"`
+ ToolCallID string `json:"tool_call_id"`
+ Output ToolResultOutputContent `json:"output"`
+ ProviderExecuted bool `json:"provider_executed"`
+ ProviderOptions ProviderOptions `json:"provider_options"`
}
// GetType returns the type of the tool result part.
@@ -711,13 +711,15 @@ func (t *ToolCallPart) UnmarshalJSON(data []byte) error {
// MarshalJSON implements json.Marshaler for ToolResultPart.
func (t ToolResultPart) MarshalJSON() ([]byte, error) {
dataBytes, err := json.Marshal(struct {
- ToolCallID string `json:"tool_call_id"`
- Output ToolResultOutputContent `json:"output"`
- ProviderOptions ProviderOptions `json:"provider_options,omitempty"`
+ ToolCallID string `json:"tool_call_id"`
+ Output ToolResultOutputContent `json:"output"`
+ ProviderExecuted bool `json:"provider_executed"`
+ ProviderOptions ProviderOptions `json:"provider_options,omitempty"`
}{
- ToolCallID: t.ToolCallID,
- Output: t.Output,
- ProviderOptions: t.ProviderOptions,
+ ToolCallID: t.ToolCallID,
+ Output: t.Output,
+ ProviderExecuted: t.ProviderExecuted,
+ ProviderOptions: t.ProviderOptions,
})
if err != nil {
return nil, err
@@ -737,9 +739,10 @@ func (t *ToolResultPart) UnmarshalJSON(data []byte) error {
}
var aux struct {
- ToolCallID string `json:"tool_call_id"`
- Output json.RawMessage `json:"output"`
- ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"`
+ ToolCallID string `json:"tool_call_id"`
+ Output json.RawMessage `json:"output"`
+ ProviderExecuted bool `json:"provider_executed"`
+ ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"`
}
if err := json.Unmarshal(mpj.Data, &aux); err != nil {
@@ -747,6 +750,7 @@ func (t *ToolResultPart) UnmarshalJSON(data []byte) error {
}
t.ToolCallID = aux.ToolCallID
+ t.ProviderExecuted = aux.ProviderExecuted
// Unmarshal the Output field
output, err := UnmarshalToolResultOutputContent(aux.Output)
@@ -1008,6 +1012,10 @@ func UnmarshalMessagePart(data []byte) (MessagePart, error) {
// UnmarshalToolResultOutputContent unmarshals JSON into the appropriate ToolResultOutputContent type.
func UnmarshalToolResultOutputContent(data []byte) (ToolResultOutputContent, error) {
+ if len(data) == 0 || string(data) == "null" {
+ return nil, nil
+ }
+
var troj toolResultOutputJSON
if err := json.Unmarshal(data, &troj); err != nil {
return nil, err
@@ -0,0 +1,69 @@
+package main
+
+// This example shows how to use provider-defined tools like Anthropic's
+// built-in web search. Provider tools are executed server-side by the model
+// provider, so there's no local tool implementation needed.
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "charm.land/fantasy"
+ "charm.land/fantasy/providers/anthropic"
+)
+
+func main() {
+ opts := []anthropic.Option{
+ anthropic.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
+ }
+ if baseURL := os.Getenv("ANTHROPIC_BASE_URL"); baseURL != "" {
+ opts = append(opts, anthropic.WithBaseURL(baseURL))
+ }
+
+ provider, err := anthropic.New(opts...)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "Error creating provider:", err)
+ os.Exit(1)
+ }
+
+ ctx := context.Background()
+
+ model, err := provider.LanguageModel(ctx, "claude-sonnet-4-20250514")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "Error creating model:", err)
+ os.Exit(1)
+ }
+
+ // Use the web search tool helper. Pass nil for defaults, or configure
+ // with options like MaxUses, AllowedDomains, and UserLocation.
+ webSearch := anthropic.WebSearchTool(nil)
+
+ agent := fantasy.NewAgent(model,
+ fantasy.WithProviderDefinedTools(webSearch),
+ )
+
+ result, err := agent.Generate(ctx, fantasy.AgentCall{
+ Prompt: "What is the current weather in San Francisco?",
+ })
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "Error:", err)
+ os.Exit(1)
+ }
+
+ // Collect all text parts. With web search the model interleaves
+ // text around tool calls, producing multiple TextContent parts.
+ var text strings.Builder
+ for _, c := range result.Response.Content {
+ if tc, ok := c.(fantasy.TextContent); ok {
+ text.WriteString(tc.Text)
+ }
+ }
+ fmt.Println(text.String())
+
+ // Print any web sources the model cited.
+ for _, source := range result.Response.Content.Sources() {
+ fmt.Printf("Source: %s — %s\n", source.Title, source.URL)
+ }
+}
@@ -440,7 +440,47 @@ func (a languageModel) toTools(tools []fantasy.Tool, toolChoice *fantasy.ToolCho
anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{OfTool: &anthropicTool})
continue
}
- // TODO: handle provider tool calls
+ if tool.GetType() == fantasy.ToolTypeProviderDefined {
+ pt, ok := tool.(fantasy.ProviderDefinedTool)
+ if !ok {
+ continue
+ }
+ switch pt.ID {
+ case "web_search":
+ webSearchTool := anthropic.WebSearchTool20250305Param{}
+ if pt.Args != nil {
+ if domains, ok := pt.Args["allowed_domains"].([]string); ok && len(domains) > 0 {
+ webSearchTool.AllowedDomains = domains
+ }
+ if domains, ok := pt.Args["blocked_domains"].([]string); ok && len(domains) > 0 {
+ webSearchTool.BlockedDomains = domains
+ }
+ if maxUses, ok := pt.Args["max_uses"].(int64); ok && maxUses > 0 {
+ webSearchTool.MaxUses = param.NewOpt(maxUses)
+ }
+ if loc, ok := pt.Args["user_location"].(*UserLocation); ok && loc != nil {
+ var ulp anthropic.UserLocationParam
+ if loc.City != "" {
+ ulp.City = param.NewOpt(loc.City)
+ }
+ if loc.Region != "" {
+ ulp.Region = param.NewOpt(loc.Region)
+ }
+ if loc.Country != "" {
+ ulp.Country = param.NewOpt(loc.Country)
+ }
+ if loc.Timezone != "" {
+ ulp.Timezone = param.NewOpt(loc.Timezone)
+ }
+ webSearchTool.UserLocation = ulp
+ }
+ }
+ anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{
+ OfWebSearchTool20250305: &webSearchTool,
+ })
+ continue
+ }
+ }
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedTool,
Tool: tool,
@@ -557,6 +597,10 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{
OfText: textBlock,
})
+ case fantasy.ContentTypeSource:
+ // Source content from web search results is not a
+ // recognized Anthropic content block type; skip it.
+ continue
case fantasy.ContentTypeFile:
file, ok := fantasy.AsMessagePart[fantasy.FilePart](part)
if !ok {
@@ -713,10 +757,22 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
continue
}
if toolCall.ProviderExecuted {
- // TODO: implement provider executed call
+ // Reconstruct server_tool_use block for
+ // multi-turn round-tripping.
+ var inputAny any
+ err := json.Unmarshal([]byte(toolCall.Input), &inputAny)
+ if err != nil {
+ continue
+ }
+ anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{
+ OfServerToolUse: &anthropic.ServerToolUseBlockParam{
+ ID: toolCall.ToolCallID,
+ Name: anthropic.ServerToolUseBlockParamName(toolCall.ToolName),
+ Input: inputAny,
+ },
+ })
continue
}
-
var inputMap map[string]any
err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
if err != nil {
@@ -728,11 +784,28 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
}
anthropicContent = append(anthropicContent, toolUseBlock)
case fantasy.ContentTypeToolResult:
- // TODO: implement provider executed tool result
+ result, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
+ if !ok {
+ continue
+ }
+ if result.ProviderExecuted {
+ // Reconstruct web_search_tool_result block
+ // with encrypted_content for round-tripping.
+ searchMeta := &WebSearchResultMetadata{}
+ if webMeta, ok := result.ProviderOptions[Name]; ok {
+ if typed, ok := webMeta.(*WebSearchResultMetadata); ok {
+ searchMeta = typed
+ }
+ }
+ anthropicContent = append(anthropicContent, buildWebSearchToolResultBlock(result.ToolCallID, searchMeta))
+ continue
+ }
+ case fantasy.ContentTypeSource: // Source content from web search results is not a
+ // recognized Anthropic content block type; skip it.
+ continue
}
}
}
-
if !hasVisibleAssistantContent(anthropicContent) {
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
@@ -757,13 +830,38 @@ func hasVisibleUserContent(content []anthropic.ContentBlockParamUnion) bool {
func hasVisibleAssistantContent(content []anthropic.ContentBlockParamUnion) bool {
for _, block := range content {
- if block.OfText != nil || block.OfToolUse != nil {
+ if block.OfText != nil || block.OfToolUse != nil || block.OfServerToolUse != nil || block.OfWebSearchToolResult != nil {
return true
}
}
return false
}
+// buildWebSearchToolResultBlock constructs an Anthropic
+// web_search_tool_result content block from structured metadata.
+func buildWebSearchToolResultBlock(toolCallID string, searchMeta *WebSearchResultMetadata) anthropic.ContentBlockParamUnion {
+ resultBlocks := make([]anthropic.WebSearchResultBlockParam, 0, len(searchMeta.Results))
+ for _, r := range searchMeta.Results {
+ block := anthropic.WebSearchResultBlockParam{
+ URL: r.URL,
+ Title: r.Title,
+ EncryptedContent: r.EncryptedContent,
+ }
+ if r.PageAge != "" {
+ block.PageAge = param.NewOpt(r.PageAge)
+ }
+ resultBlocks = append(resultBlocks, block)
+ }
+ return anthropic.ContentBlockParamUnion{
+ OfWebSearchToolResult: &anthropic.WebSearchToolResultBlockParam{
+ ToolUseID: toolCallID,
+ Content: anthropic.WebSearchToolResultBlockParamContentUnion{
+ OfWebSearchToolResultBlockItem: resultBlocks,
+ },
+ },
+ }
+}
+
func mapFinishReason(finishReason string) fantasy.FinishReason {
switch finishReason {
case "end_turn", "pause_turn", "stop_sequence":
@@ -836,6 +934,56 @@ func (a languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantas
Input: string(toolUse.Input),
ProviderExecuted: false,
})
+ case "server_tool_use":
+ serverToolUse, ok := block.AsAny().(anthropic.ServerToolUseBlock)
+ if !ok {
+ continue
+ }
+ var inputStr string
+ if b, err := json.Marshal(serverToolUse.Input); err == nil {
+ inputStr = string(b)
+ }
+ content = append(content, fantasy.ToolCallContent{
+ ToolCallID: serverToolUse.ID,
+ ToolName: string(serverToolUse.Name),
+ Input: inputStr,
+ ProviderExecuted: true,
+ })
+ case "web_search_tool_result":
+ webSearchResult, ok := block.AsAny().(anthropic.WebSearchToolResultBlock)
+ if !ok {
+ continue
+ }
+ // Extract search results as sources/citations, preserving
+ // encrypted_content for multi-turn round-tripping.
+ toolResult := fantasy.ToolResultContent{
+ ToolCallID: webSearchResult.ToolUseID,
+ ToolName: "web_search",
+ ProviderExecuted: true,
+ }
+ if items := webSearchResult.Content.OfWebSearchResultBlockArray; len(items) > 0 {
+ var metadataResults []WebSearchResultItem
+ for _, item := range items {
+ content = append(content, fantasy.SourceContent{
+ SourceType: fantasy.SourceTypeURL,
+ ID: item.URL,
+ URL: item.URL,
+ Title: item.Title,
+ })
+ metadataResults = append(metadataResults, WebSearchResultItem{
+ URL: item.URL,
+ Title: item.Title,
+ EncryptedContent: item.EncryptedContent,
+ PageAge: item.PageAge,
+ })
+ }
+ toolResult.ProviderMetadata = fantasy.ProviderMetadata{
+ Name: &WebSearchResultMetadata{
+ Results: metadataResults,
+ },
+ }
+ }
+ content = append(content, toolResult)
}
}
@@ -915,6 +1063,16 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
}) {
return
}
+ case "server_tool_use":
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeToolInputStart,
+ ID: chunk.ContentBlock.ID,
+ ToolCallName: chunk.ContentBlock.Name,
+ ToolCallInput: "",
+ ProviderExecuted: true,
+ }) {
+ return
+ }
}
case "content_block_stop":
if len(acc.Content)-1 < int(chunk.Index) {
@@ -951,6 +1109,67 @@ func (a languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.S
}) {
return
}
+ case "server_tool_use":
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeToolInputEnd,
+ ID: contentBlock.ID,
+ ProviderExecuted: true,
+ }) {
+ return
+ }
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeToolCall,
+ ID: contentBlock.ID,
+ ToolCallName: contentBlock.Name,
+ ToolCallInput: string(contentBlock.Input),
+ ProviderExecuted: true,
+ }) {
+ return
+ }
+ case "web_search_tool_result":
+ // Read search results directly from the ContentBlockUnion
+ // struct fields instead of using AsAny(). The Anthropic SDK's
+ // Accumulate re-marshals the content block at content_block_stop,
+ // which corrupts JSON.raw for inline union types like
+ // WebSearchToolResultBlockContentUnion. The struct fields
+ // themselves remain correctly populated from content_block_start.
+ var metadataResults []WebSearchResultItem
+ var providerMeta fantasy.ProviderMetadata
+ if items := contentBlock.Content.OfWebSearchResultBlockArray; len(items) > 0 {
+ for _, item := range items {
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeSource,
+ ID: item.URL,
+ SourceType: fantasy.SourceTypeURL,
+ URL: item.URL,
+ Title: item.Title,
+ }) {
+ return
+ }
+ metadataResults = append(metadataResults, WebSearchResultItem{
+ URL: item.URL,
+ Title: item.Title,
+ EncryptedContent: item.EncryptedContent,
+ PageAge: item.PageAge,
+ })
+ }
+ }
+ if len(metadataResults) > 0 {
+ providerMeta = fantasy.ProviderMetadata{
+ Name: &WebSearchResultMetadata{
+ Results: metadataResults,
+ },
+ }
+ }
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeToolResult,
+ ID: contentBlock.ToolUseID,
+ ToolCallName: "web_search",
+ ProviderExecuted: true,
+ ProviderMetadata: providerMeta,
+ }) {
+ return
+ }
}
case "content_block_delta":
switch chunk.Delta.Type {
@@ -11,6 +11,7 @@ import (
"time"
"charm.land/fantasy"
+ "github.com/charmbracelet/anthropic-sdk-go"
"github.com/stretchr/testify/require"
)
@@ -627,3 +628,528 @@ func mockAnthropicGenerateResponse() map[string]any {
},
}
}
+
+func mockAnthropicWebSearchResponse() map[string]any {
+ return map[string]any{
+ "id": "msg_01WebSearch",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-4-20250514",
+ "content": []any{
+ map[string]any{
+ "type": "server_tool_use",
+ "id": "srvtoolu_01",
+ "name": "web_search",
+ "input": map[string]any{"query": "latest AI news"},
+ "caller": map[string]any{"type": "direct"},
+ },
+ map[string]any{
+ "type": "web_search_tool_result",
+ "tool_use_id": "srvtoolu_01",
+ "caller": map[string]any{"type": "direct"},
+ "content": []any{
+ map[string]any{
+ "type": "web_search_result",
+ "url": "https://example.com/ai-news",
+ "title": "Latest AI News",
+ "encrypted_content": "encrypted_abc123",
+ "page_age": "2 hours ago",
+ },
+ map[string]any{
+ "type": "web_search_result",
+ "url": "https://example.com/ml-update",
+ "title": "ML Update",
+ "encrypted_content": "encrypted_def456",
+ "page_age": "",
+ },
+ },
+ },
+ map[string]any{
+ "type": "text",
+ "text": "Based on recent search results, here is the latest AI news.",
+ },
+ },
+ "stop_reason": "end_turn",
+ "stop_sequence": nil,
+ "usage": map[string]any{
+ "input_tokens": 100,
+ "output_tokens": 50,
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ "server_tool_use": map[string]any{
+ "web_search_requests": 1,
+ },
+ },
+ }
+}
+
+func TestToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) {
+ t.Parallel()
+
+ prompt := fantasy.Prompt{
+ // User message.
+ {
+ Role: fantasy.MessageRoleUser,
+ Content: []fantasy.MessagePart{
+ fantasy.TextPart{Text: "Search for the latest AI news"},
+ },
+ },
+ // Assistant message with a provider-executed tool call, its
+ // result, and trailing text. toResponseMessages routes
+ // provider-executed results into the assistant message, so
+ // the prompt already reflects that structure.
+ {
+ Role: fantasy.MessageRoleAssistant,
+ Content: []fantasy.MessagePart{
+ fantasy.ToolCallPart{
+ ToolCallID: "srvtoolu_01",
+ ToolName: "web_search",
+ Input: `{"query":"latest AI news"}`,
+ ProviderExecuted: true,
+ },
+ fantasy.ToolResultPart{
+ ToolCallID: "srvtoolu_01",
+ ProviderExecuted: true,
+ ProviderOptions: fantasy.ProviderOptions{
+ Name: &WebSearchResultMetadata{
+ Results: []WebSearchResultItem{
+ {
+ URL: "https://example.com/ai-news",
+ Title: "Latest AI News",
+ EncryptedContent: "encrypted_abc123",
+ PageAge: "2 hours ago",
+ },
+ {
+ URL: "https://example.com/ml-update",
+ Title: "ML Update",
+ EncryptedContent: "encrypted_def456",
+ },
+ },
+ },
+ },
+ },
+ fantasy.TextPart{Text: "Here is what I found."},
+ },
+ },
+ }
+
+ _, messages, warnings := toPrompt(prompt, true)
+
+ // No warnings expected; the provider-executed result is in the
+ // assistant message so there is no empty tool message to drop.
+ require.Empty(t, warnings)
+
+ // We should have a user message and an assistant message.
+ require.Len(t, messages, 2, "expected user + assistant messages")
+
+ assistantMsg := messages[1]
+ require.Len(t, assistantMsg.Content, 3,
+ "expected server_tool_use + web_search_tool_result + text")
+
+ // First content block: reconstructed server_tool_use.
+ serverToolUse := assistantMsg.Content[0]
+ require.NotNil(t, serverToolUse.OfServerToolUse,
+ "first block should be a server_tool_use")
+ require.Equal(t, "srvtoolu_01", serverToolUse.OfServerToolUse.ID)
+ require.Equal(t, anthropic.ServerToolUseBlockParamName("web_search"),
+ serverToolUse.OfServerToolUse.Name)
+
+ // Second content block: reconstructed web_search_tool_result with
+ // encrypted_content preserved for multi-turn round-tripping.
+ webResult := assistantMsg.Content[1]
+ require.NotNil(t, webResult.OfWebSearchToolResult,
+ "second block should be a web_search_tool_result")
+ require.Equal(t, "srvtoolu_01", webResult.OfWebSearchToolResult.ToolUseID)
+
+ results := webResult.OfWebSearchToolResult.Content.OfWebSearchToolResultBlockItem
+ require.Len(t, results, 2)
+ require.Equal(t, "https://example.com/ai-news", results[0].URL)
+ require.Equal(t, "Latest AI News", results[0].Title)
+ require.Equal(t, "encrypted_abc123", results[0].EncryptedContent)
+ require.Equal(t, "https://example.com/ml-update", results[1].URL)
+ require.Equal(t, "encrypted_def456", results[1].EncryptedContent)
+ // PageAge should be set for the first result and absent for the second.
+ require.True(t, results[0].PageAge.Valid())
+ require.Equal(t, "2 hours ago", results[0].PageAge.Value)
+ require.False(t, results[1].PageAge.Valid())
+
+ // Third content block: plain text.
+ require.NotNil(t, assistantMsg.Content[2].OfText)
+ require.Equal(t, "Here is what I found.", assistantMsg.Content[2].OfText.Text)
+}
+
+func TestGenerate_WebSearchResponse(t *testing.T) {
+ t.Parallel()
+
+ server, calls := newAnthropicJSONServer(mockAnthropicWebSearchResponse())
+ defer server.Close()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.URL),
+ )
+ require.NoError(t, err)
+
+ model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
+ require.NoError(t, err)
+
+ resp, err := model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ Tools: []fantasy.Tool{
+ WebSearchTool(nil),
+ }, })
+ require.NoError(t, err)
+
+ call := awaitAnthropicCall(t, calls)
+ require.Equal(t, "POST", call.method)
+ require.Equal(t, "/v1/messages", call.path)
+
+ // Walk the response content and categorise each item.
+ var (
+ toolCalls []fantasy.ToolCallContent
+ sources []fantasy.SourceContent
+ toolResults []fantasy.ToolResultContent
+ texts []fantasy.TextContent
+ )
+ for _, c := range resp.Content {
+ switch v := c.(type) {
+ case fantasy.ToolCallContent:
+ toolCalls = append(toolCalls, v)
+ case fantasy.SourceContent:
+ sources = append(sources, v)
+ case fantasy.ToolResultContent:
+ toolResults = append(toolResults, v)
+ case fantasy.TextContent:
+ texts = append(texts, v)
+ }
+ }
+
+ // ToolCallContent for the provider-executed web_search.
+ require.Len(t, toolCalls, 1)
+ require.True(t, toolCalls[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolCalls[0].ToolName)
+ require.Equal(t, "srvtoolu_01", toolCalls[0].ToolCallID)
+
+ // SourceContent entries for each search result.
+ require.Len(t, sources, 2)
+ require.Equal(t, "https://example.com/ai-news", sources[0].URL)
+ require.Equal(t, "Latest AI News", sources[0].Title)
+ require.Equal(t, fantasy.SourceTypeURL, sources[0].SourceType)
+ require.Equal(t, "https://example.com/ml-update", sources[1].URL)
+ require.Equal(t, "ML Update", sources[1].Title)
+
+ // ToolResultContent with provider metadata preserving encrypted_content.
+ require.Len(t, toolResults, 1)
+ require.True(t, toolResults[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolResults[0].ToolName)
+ require.Equal(t, "srvtoolu_01", toolResults[0].ToolCallID)
+
+ searchMeta, ok := toolResults[0].ProviderMetadata[Name]
+ require.True(t, ok, "providerMetadata should contain anthropic key")
+ webMeta, ok := searchMeta.(*WebSearchResultMetadata)
+ require.True(t, ok, "metadata should be *WebSearchResultMetadata")
+ require.Len(t, webMeta.Results, 2)
+ require.Equal(t, "encrypted_abc123", webMeta.Results[0].EncryptedContent)
+ require.Equal(t, "encrypted_def456", webMeta.Results[1].EncryptedContent)
+ require.Equal(t, "2 hours ago", webMeta.Results[0].PageAge)
+
+ // TextContent with the final answer.
+ require.Len(t, texts, 1)
+ require.Equal(t,
+ "Based on recent search results, here is the latest AI news.",
+ texts[0].Text,
+ )
+}
+
+func TestGenerate_WebSearchToolInRequest(t *testing.T) {
+ t.Parallel()
+
+ t.Run("basic web_search tool", func(t *testing.T) {
+ t.Parallel()
+
+ server, calls := newAnthropicJSONServer(mockAnthropicGenerateResponse())
+ defer server.Close()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.URL),
+ )
+ require.NoError(t, err)
+
+ model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
+ require.NoError(t, err)
+
+ _, err = model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ Tools: []fantasy.Tool{
+ WebSearchTool(nil),
+ },
+ })
+ require.NoError(t, err)
+
+ call := awaitAnthropicCall(t, calls)
+ tools, ok := call.body["tools"].([]any)
+ require.True(t, ok, "request body should have tools array")
+ require.Len(t, tools, 1)
+
+ tool, ok := tools[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "web_search_20250305", tool["type"])
+ })
+
+ t.Run("with allowed_domains and blocked_domains", func(t *testing.T) {
+ t.Parallel()
+
+ server, calls := newAnthropicJSONServer(mockAnthropicGenerateResponse())
+ defer server.Close()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.URL),
+ )
+ require.NoError(t, err)
+
+ model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
+ require.NoError(t, err)
+
+ _, err = model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ Tools: []fantasy.Tool{
+ WebSearchTool(&WebSearchToolOptions{
+ AllowedDomains: []string{"example.com", "test.com"},
+ }),
+ },
+ })
+ require.NoError(t, err)
+
+ call := awaitAnthropicCall(t, calls)
+ tools, ok := call.body["tools"].([]any)
+ require.True(t, ok)
+ require.Len(t, tools, 1)
+
+ tool, ok := tools[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "web_search_20250305", tool["type"])
+
+ domains, ok := tool["allowed_domains"].([]any)
+ require.True(t, ok, "tool should have allowed_domains")
+ require.Len(t, domains, 2)
+ require.Equal(t, "example.com", domains[0])
+ require.Equal(t, "test.com", domains[1])
+ })
+
+ t.Run("with max uses and user location", func(t *testing.T) {
+ t.Parallel()
+
+ server, calls := newAnthropicJSONServer(mockAnthropicGenerateResponse())
+ defer server.Close()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.URL),
+ )
+ require.NoError(t, err)
+
+ model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
+ require.NoError(t, err)
+
+ _, err = model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ Tools: []fantasy.Tool{
+ WebSearchTool(&WebSearchToolOptions{
+ MaxUses: 5,
+ UserLocation: &UserLocation{
+ City: "San Francisco",
+ Country: "US",
+ },
+ }),
+ },
+ })
+ require.NoError(t, err)
+
+ call := awaitAnthropicCall(t, calls)
+ tools, ok := call.body["tools"].([]any)
+ require.True(t, ok)
+ require.Len(t, tools, 1)
+
+ tool, ok := tools[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "web_search_20250305", tool["type"])
+
+ // max_uses is serialized as a JSON number; json.Unmarshal
+ // into map[string]any decodes numbers as float64.
+ maxUses, ok := tool["max_uses"].(float64)
+ require.True(t, ok, "tool should have max_uses")
+ require.Equal(t, float64(5), maxUses)
+
+ userLoc, ok := tool["user_location"].(map[string]any)
+ require.True(t, ok, "tool should have user_location")
+ require.Equal(t, "San Francisco", userLoc["city"])
+ require.Equal(t, "US", userLoc["country"])
+ require.Equal(t, "approximate", userLoc["type"])
+ })
+
+ t.Run("with max uses", func(t *testing.T) {
+ t.Parallel()
+
+ server, calls := newAnthropicJSONServer(mockAnthropicGenerateResponse())
+ defer server.Close()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.URL),
+ )
+ require.NoError(t, err)
+
+ model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
+ require.NoError(t, err)
+
+ _, err = model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ Tools: []fantasy.Tool{
+ WebSearchTool(&WebSearchToolOptions{
+ MaxUses: 3,
+ }),
+ },
+ })
+ require.NoError(t, err)
+
+ call := awaitAnthropicCall(t, calls)
+ tools, ok := call.body["tools"].([]any)
+ require.True(t, ok)
+ require.Len(t, tools, 1)
+
+ tool, ok := tools[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "web_search_20250305", tool["type"])
+
+ maxUses, ok := tool["max_uses"].(float64)
+ require.True(t, ok, "tool should have max_uses")
+ require.Equal(t, float64(3), maxUses)
+ })
+}
+
+func TestStream_WebSearchResponse(t *testing.T) {
+ t.Parallel()
+
+ // Build SSE chunks that simulate a web search streaming response.
+ // The Anthropic SDK accumulates content blocks via
+ // acc.Accumulate(event). We read the Content and ToolUseID
+ // directly from struct fields instead of using AsAny(), which
+ // avoids the SDK's re-marshal limitation that previously dropped
+ // source data.
+ webSearchResultContent, _ := json.Marshal([]any{
+ map[string]any{
+ "type": "web_search_result",
+ "url": "https://example.com/ai-news",
+ "title": "Latest AI News",
+ "encrypted_content": "encrypted_abc123",
+ "page_age": "2 hours ago",
+ },
+ })
+
+ chunks := []string{
+ // message_start
+ "event: message_start\n",
+ `data: {"type":"message_start","message":{"id":"msg_01WebSearch","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"usage":{"input_tokens":100,"output_tokens":0}}}` + "\n\n",
+ // Block 0: server_tool_use
+ "event: content_block_start\n",
+ `data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_01","name":"web_search","input":{}}}` + "\n\n",
+ "event: content_block_stop\n",
+ `data: {"type":"content_block_stop","index":0}` + "\n\n",
+ // Block 1: web_search_tool_result
+ "event: content_block_start\n",
+ `data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01","content":` + string(webSearchResultContent) + `}}` + "\n\n",
+ "event: content_block_stop\n",
+ `data: {"type":"content_block_stop","index":1}` + "\n\n",
+ // Block 2: text
+ "event: content_block_start\n",
+ `data: {"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}}` + "\n\n",
+ "event: content_block_delta\n",
+ `data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"Here are the results."}}` + "\n\n",
+ "event: content_block_stop\n",
+ `data: {"type":"content_block_stop","index":2}` + "\n\n",
+ // message_stop
+ "event: message_stop\n",
+ `data: {"type":"message_stop"}` + "\n\n",
+ }
+
+ server, calls := newAnthropicStreamingServer(chunks)
+ defer server.Close()
+
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(server.URL),
+ )
+ require.NoError(t, err)
+
+ model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
+ require.NoError(t, err)
+
+ stream, err := model.Stream(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ Tools: []fantasy.Tool{
+ WebSearchTool(nil),
+ }, })
+ require.NoError(t, err)
+
+ var parts []fantasy.StreamPart
+ stream(func(part fantasy.StreamPart) bool {
+ parts = append(parts, part)
+ return true
+ })
+
+ _ = awaitAnthropicCall(t, calls)
+
+ // Collect parts by type for assertions.
+ var (
+ toolInputStarts []fantasy.StreamPart
+ toolCalls []fantasy.StreamPart
+ toolResults []fantasy.StreamPart
+ sourceParts []fantasy.StreamPart
+ textDeltas []fantasy.StreamPart
+ )
+ for _, p := range parts {
+ switch p.Type {
+ case fantasy.StreamPartTypeToolInputStart:
+ toolInputStarts = append(toolInputStarts, p)
+ case fantasy.StreamPartTypeToolCall:
+ toolCalls = append(toolCalls, p)
+ case fantasy.StreamPartTypeToolResult:
+ toolResults = append(toolResults, p)
+ case fantasy.StreamPartTypeSource:
+ sourceParts = append(sourceParts, p)
+ case fantasy.StreamPartTypeTextDelta:
+ textDeltas = append(textDeltas, p)
+ }
+ }
+
+ // server_tool_use emits a ToolInputStart with ProviderExecuted.
+ require.NotEmpty(t, toolInputStarts, "should have a tool input start")
+ require.True(t, toolInputStarts[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolInputStarts[0].ToolCallName)
+
+ // server_tool_use emits a ToolCall with ProviderExecuted.
+ require.NotEmpty(t, toolCalls, "should have a tool call")
+ require.True(t, toolCalls[0].ProviderExecuted)
+
+ // web_search_tool_result always emits a ToolResult even when
+ // the SDK drops source data. The ToolUseID comes from the raw
+ // union field as a fallback.
+ require.NotEmpty(t, toolResults, "should have a tool result")
+ require.True(t, toolResults[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolResults[0].ToolCallName)
+ require.Equal(t, "srvtoolu_01", toolResults[0].ID,
+ "tool result ID should match the tool_use_id")
+
+ // Source parts are now correctly emitted by reading struct fields
+ // directly instead of using AsAny().
+ require.Len(t, sourceParts, 1)
+ require.Equal(t, "https://example.com/ai-news", sourceParts[0].URL)
+ require.Equal(t, "Latest AI News", sourceParts[0].Title)
+ require.Equal(t, fantasy.SourceTypeURL, sourceParts[0].SourceType)
+
+ // Text block emits a text delta.
+ require.NotEmpty(t, textDeltas, "should have text deltas")
+ require.Equal(t, "Here are the results.", textDeltas[0].Delta)
+}
@@ -28,6 +28,7 @@ const (
TypeProviderOptions = Name + ".options"
TypeReasoningOptionMetadata = Name + ".reasoning_metadata"
TypeProviderCacheControl = Name + ".cache_control_options"
+ TypeWebSearchResultMetadata = Name + ".web_search_result_metadata"
)
// Register Anthropic provider-specific types with the global registry.
@@ -53,6 +54,13 @@ func init() {
}
return &v, nil
})
+ fantasy.RegisterProviderType(TypeWebSearchResultMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
+ var v WebSearchResultMetadata
+ if err := json.Unmarshal(data, &v); err != nil {
+ return nil, err
+ }
+ return &v, nil
+ })
}
// ProviderOptions represents additional options for the Anthropic provider.
@@ -139,6 +147,42 @@ func (o *ProviderCacheControlOptions) UnmarshalJSON(data []byte) error {
return nil
}
+// WebSearchResultItem represents a single web search result for round-tripping.
+type WebSearchResultItem struct {
+ URL string `json:"url"`
+ Title string `json:"title"`
+ EncryptedContent string `json:"encrypted_content"`
+ // PageAge may be empty when the API does not return age info.
+ PageAge string `json:"page_age,omitempty"`
+}
+
+// WebSearchResultMetadata stores web search results from Anthropic's
+// server-executed web_search tool. The structured data (especially
+// EncryptedContent) must be preserved for multi-turn conversations.
+type WebSearchResultMetadata struct {
+ Results []WebSearchResultItem `json:"results"`
+}
+
+// Options implements the ProviderOptions interface.
+func (*WebSearchResultMetadata) Options() {}
+
+// MarshalJSON implements custom JSON marshaling with type info for WebSearchResultMetadata.
+func (m WebSearchResultMetadata) MarshalJSON() ([]byte, error) {
+ type plain WebSearchResultMetadata
+ return fantasy.MarshalProviderType(TypeWebSearchResultMetadata, plain(m))
+}
+
+// UnmarshalJSON implements custom JSON unmarshaling with type info for WebSearchResultMetadata.
+func (m *WebSearchResultMetadata) UnmarshalJSON(data []byte) error {
+ type plain WebSearchResultMetadata
+ var p plain
+ if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
+ return err
+ }
+ *m = WebSearchResultMetadata(p)
+ return nil
+}
+
// CacheControl represents cache control settings for the Anthropic provider.
type CacheControl struct {
Type string `json:"type"`
@@ -166,3 +210,56 @@ func ParseOptions(data map[string]any) (*ProviderOptions, error) {
}
return &options, nil
}
+
+// UserLocation provides geographic context for web search results.
+type UserLocation struct {
+ City string `json:"city,omitempty"`
+ Region string `json:"region,omitempty"`
+ Country string `json:"country,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+}
+
+// WebSearchToolOptions configures the Anthropic web search tool.
+type WebSearchToolOptions struct {
+ // MaxUses limits the number of web searches the model can
+ // perform within a single API request. Zero means no limit.
+ MaxUses int64
+ // AllowedDomains restricts results to these domains. Cannot
+ // be used together with BlockedDomains.
+ AllowedDomains []string
+ // BlockedDomains excludes these domains from results. Cannot
+ // be used together with AllowedDomains.
+ BlockedDomains []string
+ // UserLocation provides geographic context for more relevant
+ // search results.
+ UserLocation *UserLocation
+}
+
+// WebSearchTool creates a provider-defined web search tool for
+// Anthropic models. Pass nil for default options.
+func WebSearchTool(opts *WebSearchToolOptions) fantasy.ProviderDefinedTool {
+ tool := fantasy.ProviderDefinedTool{
+ ID: "web_search",
+ Name: "web_search",
+ }
+ if opts == nil {
+ return tool
+ }
+ args := map[string]any{}
+ if opts.MaxUses > 0 {
+ args["max_uses"] = opts.MaxUses
+ }
+ if len(opts.AllowedDomains) > 0 {
+ args["allowed_domains"] = opts.AllowedDomains
+ }
+ if len(opts.BlockedDomains) > 0 {
+ args["blocked_domains"] = opts.BlockedDomains
+ }
+ if opts.UserLocation != nil {
+ args["user_location"] = opts.UserLocation
+ }
+ if len(args) > 0 {
+ tool.Args = args
+ }
+ return tool
+}
@@ -155,3 +155,122 @@ func anthropicBuilder(model string) builderFunc {
return provider.LanguageModel(t.Context(), model)
}
}
+
+// TestAnthropicWebSearch tests web search tool support via the agent
+// using WithProviderDefinedTools.
+func TestAnthropicWebSearch(t *testing.T) {
+ model := "claude-sonnet-4-20250514"
+ webSearchTool := anthropic.WebSearchTool(nil)
+
+ t.Run("generate", func(t *testing.T) {
+ r := vcr.NewRecorder(t)
+
+ lm, err := anthropicBuilder(model)(t, r)
+ require.NoError(t, err)
+
+ agent := fantasy.NewAgent(
+ lm,
+ fantasy.WithSystemPrompt("You are a helpful assistant"),
+ fantasy.WithProviderDefinedTools(webSearchTool),
+ )
+
+ result, err := agent.Generate(t.Context(), fantasy.AgentCall{
+ Prompt: "What is the current population of Tokyo? Cite your source.",
+ MaxOutputTokens: fantasy.Opt(int64(4000)),
+ })
+ require.NoError(t, err)
+
+ got := result.Response.Content.Text()
+ require.NotEmpty(t, got, "should have a text response")
+ require.Contains(t, got, "Tokyo", "response should mention Tokyo")
+
+ // Walk the steps and verify web search content was produced.
+ var sources []fantasy.SourceContent
+ var providerToolCalls []fantasy.ToolCallContent
+ for _, step := range result.Steps {
+ for _, c := range step.Content {
+ switch v := c.(type) {
+ case fantasy.ToolCallContent:
+ if v.ProviderExecuted {
+ providerToolCalls = append(providerToolCalls, v)
+ }
+ case fantasy.SourceContent:
+ sources = append(sources, v)
+ }
+ }
+ }
+
+ require.NotEmpty(t, providerToolCalls, "should have provider-executed tool calls")
+ require.Equal(t, "web_search", providerToolCalls[0].ToolName)
+ require.NotEmpty(t, sources, "should have source citations")
+ require.NotEmpty(t, sources[0].URL, "source should have a URL")
+ })
+
+ t.Run("stream", func(t *testing.T) {
+ r := vcr.NewRecorder(t)
+
+ lm, err := anthropicBuilder(model)(t, r)
+ require.NoError(t, err)
+
+ agent := fantasy.NewAgent(
+ lm,
+ fantasy.WithSystemPrompt("You are a helpful assistant"),
+ fantasy.WithProviderDefinedTools(webSearchTool),
+ )
+
+ // Turn 1: initial query triggers web search.
+ result, err := agent.Stream(t.Context(), fantasy.AgentStreamCall{
+ Prompt: "What is the current population of Tokyo? Cite your source.",
+ MaxOutputTokens: fantasy.Opt(int64(4000)),
+ })
+ require.NoError(t, err)
+
+ got := result.Response.Content.Text()
+ require.NotEmpty(t, got, "should have a text response")
+ require.Contains(t, got, "Tokyo", "response should mention Tokyo")
+
+ // Verify provider-executed tool calls and results in steps.
+ var providerToolCalls []fantasy.ToolCallContent
+ var providerToolResults []fantasy.ToolResultContent
+ for _, step := range result.Steps {
+ for _, c := range step.Content {
+ switch v := c.(type) {
+ case fantasy.ToolCallContent:
+ if v.ProviderExecuted {
+ providerToolCalls = append(providerToolCalls, v)
+ }
+ case fantasy.ToolResultContent:
+ if v.ProviderExecuted {
+ providerToolResults = append(providerToolResults, v)
+ }
+ }
+ }
+ }
+ require.NotEmpty(t, providerToolCalls, "should have provider-executed tool calls")
+ require.Equal(t, "web_search", providerToolCalls[0].ToolName)
+ require.NotEmpty(t, providerToolResults, "should have provider-executed tool results")
+
+ // Turn 2: follow-up using step messages from turn 1.
+ // This verifies that the web_search_tool_result block
+ // round-trips correctly through toPrompt.
+ var history fantasy.Prompt
+ history = append(history, fantasy.Message{
+ Role: fantasy.MessageRoleUser,
+ Content: []fantasy.MessagePart{fantasy.TextPart{Text: "What is the current population of Tokyo? Cite your source."}},
+ })
+ for _, step := range result.Steps {
+ history = append(history, step.Messages...)
+ }
+
+ result2, err := agent.Stream(t.Context(), fantasy.AgentStreamCall{
+ Messages: history,
+ Prompt: "How does that compare to Osaka?",
+ MaxOutputTokens: fantasy.Opt(int64(4000)),
+ })
+ require.NoError(t, err)
+
+ got2 := result2.Response.Content.Text()
+ require.NotEmpty(t, got2, "turn 2 should have a text response")
+ require.Contains(t, got2, "Osaka", "turn 2 response should mention Osaka")
+ })
+}
@@ -391,6 +391,7 @@ func TestProviderRegistry_AllTypesRegistered(t *testing.T) {
}{
{"OpenAI Responses Reasoning Metadata", openai.Name, &openai.ResponsesReasoningMetadata{}},
{"Anthropic Reasoning Metadata", anthropic.Name, &anthropic.ReasoningOptionMetadata{}},
+ {"Anthropic Web Search Result Metadata", anthropic.Name, &anthropic.WebSearchResultMetadata{}},
{"Google Reasoning Metadata", google.Name, &google.ReasoningMetadata{}},
{"OpenRouter Metadata", openrouter.Name, &openrouter.ProviderMetadata{}},
}
@@ -0,0 +1,33 @@
+---
+version: 2
+interactions:
+- id: 0
+ request:
+ proto: HTTP/1.1
+ proto_major: 1
+ proto_minor: 1
+ content_length: 368
+ host: ""
+ body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What is the current population of Tokyo? Cite your source.","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"name":"web_search","type":"web_search_20250305"}]}'
+ headers:
+ Accept:
+ - application/json
+ Content-Type:
+ - application/json
+ User-Agent:
+ - Charm Fantasy/0.12.0
+ url: https://api.anthropic.com/v1/messages
+ method: POST
+ response:
+ proto: HTTP/2.0
+ proto_major: 2
+ proto_minor: 0
+ content_length: -1
+ uncompressed: true
@@ -0,0 +1,1107 @@
+---
+version: 2
+interactions:
+ - id: 0
+ request:
+ proto: HTTP/1.1
+ proto_major: 1
+ proto_minor: 1
+ content_length: 382
+ host: ""
+ body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What is the current population of Tokyo? Cite your source.","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"name":"web_search","type":"web_search_20250305"}],"stream":true}'
+ headers:
+ Accept:
+ - application/json
+ Content-Type:
+ - application/json
+ User-Agent:
+ - Charm Fantasy/0.12.0
+ url: https://api.anthropic.com/v1/messages
+ method: POST
+ response:
+ proto: HTTP/2.0
+ proto_major: 2
+ proto_minor: 0
+ content_length: -1
+ uncompressed: true
+ body: |+
+ event: message_start
+ data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_019wEazncxZZExmW7sTECaeK","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2049,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}} }
+
+ event: content_block_start
+ data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_01KYmV1c6SjLeWGNcNG4p2yi","name":"web_search","input":{},"caller":{"type":"direct"}} }
+
+ event: ping
+ data: {"type": "ping"}
+
+ event: content_block_delta
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""} }
+
+ event: content_block_delta
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":"} }
+
+ event: content_block_delta
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" \"Tok"}}
+
+ event: content_block_delta
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"yo"} }
+
+ event: content_block_delta
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" popula"} }
+
+ event: content_block_delta
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"tion 2026 c"} }
+
+ event: content_block_delta
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"urrent\"}"} }
+
+ event: content_block_stop
+ data: {"type":"content_block_stop","index":0 }
+
+ event: content_block_start