feat: add Anthropic web search tool support (server_tool_use + web_search_tool_result)

Kyle Carberry created

- Handle ProviderDefinedTool with ID "web_search" in toTools(), mapping to
  anthropic.WebSearchTool20250305Param with optional allowed/blocked domains
- Parse "server_tool_use" blocks in Generate() and Stream(), producing
  ToolCallContent with ProviderExecuted=true
- Parse "web_search_tool_result" blocks, extracting SourceContent for each
  search result and a ToolResultContent summary
- Stream server_tool_use via ToolInputStart/ToolInputEnd/ToolCall events
- Stream web_search_tool_result via Source events for each result item
- Skip provider-executed tool results and source content when building
  Anthropic prompts (toPrompt) to avoid sending unrecognized block types
- Add ProviderExecuted field to ToolResultPart with JSON marshal/unmarshal
- Propagate ProviderExecuted from ToolResultContent to ToolResultPart in
  agent's toResponseMessages()

Change summary

agent.go                                                    | 133 +
agent_test.go                                               |  81 +
content.go                                                  |   7 
content_json.go                                             |  26 
examples/provider-tools/main.go                             |  69 
providers/anthropic/anthropic.go                            | 231 ++
providers/anthropic/anthropic_test.go                       | 526 +++++++
providers/anthropic/provider_options.go                     |  97 +
providertests/anthropic_test.go                             | 119 +
providertests/provider_registry_test.go                     |   1 
providertests/testdata/TestAnthropicWebSearch/generate.yaml |  26 
providertests/testdata/TestAnthropicWebSearch/stream.yaml   |  61 
12 files changed, 1,324 insertions(+), 53 deletions(-)

Detailed changes

agent.go 🔗

@@ -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{

agent_test.go 🔗

@@ -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)
+}

content.go 🔗

@@ -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.

content_json.go 🔗

@@ -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

examples/provider-tools/main.go 🔗

@@ -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)
+	}
+}

providers/anthropic/anthropic.go 🔗

@@ -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 {

providers/anthropic/anthropic_test.go 🔗

@@ -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)
+}

providers/anthropic/provider_options.go 🔗

@@ -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
+}

providertests/anthropic_test.go 🔗

@@ -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")
+	})
+}

providertests/provider_registry_test.go 🔗

@@ -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{}},
 	}

providertests/testdata/TestAnthropicWebSearch/generate.yaml 🔗

@@ -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

providertests/testdata/TestAnthropicWebSearch/stream.yaml 🔗

@@ -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