From 57dc23f603b5ac61d54a539e6a87b85d1a0b5b2f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 11 Mar 2026 14:33:15 -0700 Subject: [PATCH] feat(chatd): add provider-native web search tools to chats (#22909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds provider-native web search tools to the chat system. Anthropic, OpenAI, and Google all offer server-side web search — this wires them up as opt-in per-model config options using the existing `ChatModelProviderOptions` JSONB column (no migration). Web search is **off by default**. ## Config Set `web_search_enabled: true` in the model config provider options: ```json { "provider_options": { "anthropic": { "web_search_enabled": true, "allowed_domains": ["docs.coder.com", "github.com"] } } } ``` Available options per provider: - **Anthropic**: `web_search_enabled`, `allowed_domains`, `blocked_domains` - **OpenAI**: `web_search_enabled`, `search_context_size` (`low`/`medium`/`high`), `allowed_domains` - **Google**: `web_search_enabled` ## Backend - `codersdk/chats.go` — new fields on the per-provider option structs - `coderd/chatd/chatd.go` — `buildProviderTools()` reads config, creates `ProviderDefinedTool` entries (uses `anthropic.WebSearchTool()` helper from fantasy) - `coderd/chatd/chatloop/chatloop.go` — `ProviderTools` on `RunOptions`, merged into `Call.Tools`. Provider-executed tool calls skip local execution. `StreamPartTypeToolResult` with `ProviderExecuted: true` is accumulated inline (matching fantasy's own agent.go pattern) instead of post-stream synthesis. - `coderd/chatd/chatprompt/` — `MarshalToolResult` carries `ProviderMetadata` through DB persistence so multi-turn round-trips work (Anthropic needs `encrypted_content` back) ## Frontend - Source citations render **inline** at the tool-call position (not bottom-of-message), using `ToolCollapsible` so they look like other tool cards — collapsed "Searched N results" with globe icon, expand to see source pills - Provider-executed tool calls/results are hidden from the normal tool card UI - Tool-role messages with only provider-executed results return `null` (no empty bubble) - Both persisted (messageParsing.ts) and streaming (streamState.ts) paths group consecutive `source` parts into a single `{ type: "sources" }` render block ## Fantasy changes The fantasy fork (`kylecarbs/fantasy` branch `cj/go1.25`) has the Anthropic tool code merged in, but will hopefully go upstream from: https://github.com/charmbracelet/fantasy/pull/163 --- coderd/chatd/chatd.go | 53 ++++++++- coderd/chatd/chatloop/chatloop.go | 88 +++++++++++--- coderd/chatd/chatprompt/chatprompt.go | 98 ++++++++++++---- coderd/chatd/chatprompt/chatprompt_test.go | 5 + coderd/database/db2sdk/db2sdk.go | 38 +++--- codersdk/chats.go | 48 ++++---- go.mod | 42 +++---- go.sum | 76 ++++++------ site/src/api/chatModelOptionsGenerated.json | 57 +++++++++ site/src/api/typesGenerated.ts | 8 ++ .../ai-elements/tool/WebSearchSources.tsx | 111 ++++++++++++++++++ site/src/components/ai-elements/tool/index.ts | 1 + .../AgentDetail/ConversationTimeline.tsx | 24 +++- .../AgentDetail/StreamingOutput.stories.tsx | 2 + .../AgentsPage/AgentDetail/messageParsing.ts | 39 ++++++ .../AgentDetail/streamState.test.ts | 4 + .../AgentsPage/AgentDetail/streamState.ts | 48 +++++++- .../src/pages/AgentsPage/AgentDetail/types.ts | 6 + .../useWorkspaceCreationWatcher.test.tsx | 1 + 19 files changed, 606 insertions(+), 143 deletions(-) create mode 100644 site/src/components/ai-elements/tool/WebSearchSources.tsx diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go index 513bbba1c8..5a8f0aaa14 100644 --- a/coderd/chatd/chatd.go +++ b/coderd/chatd/chatd.go @@ -12,6 +12,7 @@ import ( "time" "charm.land/fantasy" + "charm.land/fantasy/providers/anthropic" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" @@ -2124,7 +2125,7 @@ func (p *Server) runChat( p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, providerKeys, logger) }() - prompt, err := chatprompt.ConvertMessagesWithFiles(ctx, messages, p.chatFileResolver()) + prompt, err := chatprompt.ConvertMessagesWithFiles(ctx, messages, p.chatFileResolver(), logger) if err != nil { return xerrors.Errorf("build chat prompt: %w", err) } @@ -2492,6 +2493,13 @@ func (p *Server) runChat( })...) } + // Build provider-native tools (e.g., web search) based on + // the model configuration. + var providerTools []fantasy.Tool + if callConfig.ProviderOptions != nil { + providerTools = buildProviderTools(model.Provider(), callConfig.ProviderOptions) + } + err = chatloop.Run(ctx, chatloop.RunOptions{ Model: model, Messages: prompt, @@ -2500,6 +2508,7 @@ func (p *Server) runChat( ModelConfig: callConfig, ProviderOptions: chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions), + ProviderTools: providerTools, ContextLimitFallback: modelConfigContextLimit, @@ -2516,7 +2525,7 @@ func (p *Server) runChat( if err != nil { return nil, xerrors.Errorf("reload chat messages: %w", err) } - reloadedPrompt, err := chatprompt.ConvertMessagesWithFiles(reloadCtx, reloadedMsgs, p.chatFileResolver()) + reloadedPrompt, err := chatprompt.ConvertMessagesWithFiles(reloadCtx, reloadedMsgs, p.chatFileResolver(), logger) if err != nil { return nil, xerrors.Errorf("convert reloaded messages: %w", err) } @@ -2564,6 +2573,44 @@ func (p *Server) runChat( return err } +// buildProviderTools creates provider-native tool definitions +// (like web search) based on the model configuration. These +// tools are executed server-side by the LLM provider. +func buildProviderTools(_ string, options *codersdk.ChatModelProviderOptions) []fantasy.Tool { + var tools []fantasy.Tool + + if options.Anthropic != nil && options.Anthropic.WebSearchEnabled != nil && *options.Anthropic.WebSearchEnabled { + tools = append(tools, anthropic.WebSearchTool(&anthropic.WebSearchToolOptions{ + AllowedDomains: options.Anthropic.AllowedDomains, + BlockedDomains: options.Anthropic.BlockedDomains, + })) + } + + if options.OpenAI != nil && options.OpenAI.WebSearchEnabled != nil && *options.OpenAI.WebSearchEnabled { + args := map[string]any{} + if options.OpenAI.SearchContextSize != nil && *options.OpenAI.SearchContextSize != "" { + args["search_context_size"] = *options.OpenAI.SearchContextSize + } + if len(options.OpenAI.AllowedDomains) > 0 { + args["allowed_domains"] = options.OpenAI.AllowedDomains + } + tools = append(tools, fantasy.ProviderDefinedTool{ + ID: "web_search", + Name: "web_search", + Args: args, + }) + } + + if options.Google != nil && options.Google.WebSearchEnabled != nil && *options.Google.WebSearchEnabled { + tools = append(tools, fantasy.ProviderDefinedTool{ + ID: "web_search", + Name: "web_search", + }) + } + + return tools +} + // persistChatContextSummary persists a chat context summary to the database. // This is invoked via the chat loop's compaction callback. func (p *Server) persistChatContextSummary( @@ -2618,6 +2665,8 @@ func (p *Server) persistChatContextSummary( "chat_summarized", summaryResult, false, + false, + nil, ) if err != nil { return xerrors.Errorf("encode summary tool result: %w", err) diff --git a/coderd/chatd/chatloop/chatloop.go b/coderd/chatd/chatloop/chatloop.go index 1beec43917..f7a996bc41 100644 --- a/coderd/chatd/chatloop/chatloop.go +++ b/coderd/chatd/chatloop/chatloop.go @@ -63,6 +63,12 @@ type RunOptions struct { // of the provider, which lives in chatd, not chatloop. ProviderOptions fantasy.ProviderOptions + // ProviderTools are provider-native tools (like web search) + // that are passed directly to the provider API alongside + // function tool definitions. These are not necessarily + // executed server-side; handling is provider-specific. + ProviderTools []fantasy.Tool + PersistStep func(context.Context, PersistedStep) error PublishMessagePart func( role fantasy.MessageRole, @@ -153,9 +159,10 @@ func (r stepResult) toResponseMessages() []fantasy.Message { continue } toolParts = append(toolParts, fantasy.ToolResultPart{ - ToolCallID: result.ToolCallID, - Output: result.Result, - ProviderOptions: fantasy.ProviderOptions(result.ProviderMetadata), + ToolCallID: result.ToolCallID, + Output: result.Result, + ProviderExecuted: result.ProviderExecuted, + ProviderOptions: fantasy.ProviderOptions(result.ProviderMetadata), }) default: continue @@ -205,7 +212,7 @@ func Run(ctx context.Context, opts RunOptions) error { opts.PublishMessagePart(role, part) } - tools := buildToolDefinitions(opts.Tools, opts.ActiveTools) + tools := buildToolDefinitions(opts.Tools, opts.ActiveTools, opts.ProviderTools) applyAnthropicCaching := shouldApplyAnthropicPromptCaching(opts.Model) messages := opts.Messages @@ -316,7 +323,6 @@ func Run(ctx context.Context, opts RunOptions) error { Valid: true, } } - // Persist the step — errors propagate directly. if err := opts.PersistStep(ctx, PersistedStep{ Content: result.content, @@ -494,17 +500,19 @@ func processStepStream( } case fantasy.StreamPartTypeToolInputDelta: + var providerExecuted bool if toolCall, exists := activeToolCalls[part.ID]; exists { toolCall.Input += part.Delta + providerExecuted = toolCall.ProviderExecuted } toolName := toolNames[part.ID] publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: part.ID, - ToolName: toolName, - ArgsDelta: part.Delta, + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: part.ID, + ToolName: toolName, + ArgsDelta: part.Delta, + ProviderExecuted: providerExecuted, }) - case fantasy.StreamPartTypeToolInputEnd: // No callback needed; the full tool call arrives in // StreamPartTypeToolCall. @@ -544,6 +552,24 @@ func processStepStream( chatprompt.PartFromContent(sourceContent), ) + case fantasy.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. + // This mirrors fantasy's agent.go accumulation logic. + if part.ProviderExecuted { + tr := fantasy.ToolResultContent{ + ToolCallID: part.ID, + ToolName: part.ToolCallName, + ProviderExecuted: part.ProviderExecuted, + ProviderMetadata: part.ProviderMetadata, + } + result.content = append(result.content, tr) + publishMessagePart( + fantasy.MessageRoleTool, + chatprompt.PartFromContent(tr), + ) + } case fantasy.StreamPartTypeFinish: result.usage = part.Usage result.finishReason = part.FinishReason @@ -571,7 +597,14 @@ func processStepStream( } } - result.shouldContinue = len(result.toolCalls) > 0 && + hasLocalToolCalls := false + for _, tc := range result.toolCalls { + if !tc.ProviderExecuted { + hasLocalToolCalls = true + break + } + } + result.shouldContinue = hasLocalToolCalls && result.finishReason == fantasy.FinishReasonToolCalls return result, nil } @@ -590,15 +623,29 @@ func executeTools( return nil } + // Filter out provider-executed tool calls. These were + // handled server-side by the LLM provider (e.g., web + // search) and their results are already in the stream + // content. + localToolCalls := make([]fantasy.ToolCallContent, 0, len(toolCalls)) + for _, tc := range toolCalls { + if !tc.ProviderExecuted { + localToolCalls = append(localToolCalls, tc) + } + } + if len(localToolCalls) == 0 { + return nil + } + toolMap := make(map[string]fantasy.AgentTool, len(allTools)) for _, t := range allTools { toolMap[t.Info().Name] = t } - results := make([]fantasy.ToolResultContent, len(toolCalls)) + results := make([]fantasy.ToolResultContent, len(localToolCalls)) var wg sync.WaitGroup - wg.Add(len(toolCalls)) - for i, tc := range toolCalls { + wg.Add(len(localToolCalls)) + for i, tc := range localToolCalls { go func(i int, tc fantasy.ToolCallContent) { defer wg.Done() defer func() { @@ -770,8 +817,9 @@ func persistInterruptedStep( continue } content = append(content, fantasy.ToolResultContent{ - ToolCallID: tc.ToolCallID, - ToolName: tc.ToolName, + ToolCallID: tc.ToolCallID, + ToolName: tc.ToolName, + ProviderExecuted: tc.ProviderExecuted, Result: fantasy.ToolResultOutputContentError{ Error: xerrors.New(interruptedToolResultErrorMessage), }, @@ -791,9 +839,10 @@ func persistInterruptedStep( // buildToolDefinitions converts AgentTool definitions into the // fantasy.Tool slice expected by fantasy.Call. When activeTools -// is non-empty, only tools whose name appears in the list are -// included. This mirrors fantasy's agent.prepareTools filtering. -func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string) []fantasy.Tool { +// is non-empty, only function tools whose name appears in the +// list are included. Provider tools bypass this filter and are +// always appended unconditionally. +func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, providerTools []fantasy.Tool) []fantasy.Tool { prepared := make([]fantasy.Tool, 0, len(tools)) for _, tool := range tools { info := tool.Info() @@ -813,6 +862,7 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string) []fan ProviderOptions: tool.ProviderOptions(), }) } + prepared = append(prepared, providerTools...) return prepared } diff --git a/coderd/chatd/chatprompt/chatprompt.go b/coderd/chatd/chatprompt/chatprompt.go index df72f5e365..1c76ea0f82 100644 --- a/coderd/chatd/chatprompt/chatprompt.go +++ b/coderd/chatd/chatprompt/chatprompt.go @@ -11,6 +11,7 @@ import ( "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/codersdk" ) @@ -110,7 +111,7 @@ func patchFileContent( func ConvertMessages( messages []database.ChatMessage, ) ([]fantasy.Message, error) { - return ConvertMessagesWithFiles(context.Background(), messages, nil) + return ConvertMessagesWithFiles(context.Background(), messages, nil, slog.Logger{}) } // ConvertMessagesWithFiles converts persisted chat messages into LLM @@ -121,6 +122,7 @@ func ConvertMessagesWithFiles( ctx context.Context, messages []database.ChatMessage, resolver FileResolver, + logger slog.Logger, ) ([]fantasy.Message, error) { // Phase 1: Pre-scan user messages for file_id references. var allFileIDs []uuid.UUID @@ -229,7 +231,7 @@ func ConvertMessagesWithFiles( if row.ToolCallID != "" && row.ToolName != "" { toolNameByCallID[sanitizeToolCallID(row.ToolCallID)] = row.ToolName } - parts = append(parts, row.toToolResultPart()) + parts = append(parts, row.toToolResultPart(logger)) } prompt = append(prompt, fantasy.Message{ Role: fantasy.MessageRoleTool, @@ -359,10 +361,12 @@ func ParseContent(role string, raw pqtype.NullRawMessage) ([]fantasy.Content, er // result row. We intentionally avoid a strict Go struct so that // historical shapes are never rejected. type toolResultRaw struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Result json.RawMessage `json:"result"` - IsError bool `json:"is_error,omitempty"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Result json.RawMessage `json:"result"` + IsError bool `json:"is_error,omitempty"` + ProviderExecuted bool `json:"provider_executed,omitempty"` + ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` } // parseToolResultRows decodes persisted tool result rows. @@ -378,7 +382,7 @@ func parseToolResultRows(raw pqtype.NullRawMessage) ([]toolResultRaw, error) { return rows, nil } -func (r toolResultRaw) toToolResultPart() fantasy.ToolResultPart { +func (r toolResultRaw) toToolResultPart(logger slog.Logger) fantasy.ToolResultPart { toolCallID := sanitizeToolCallID(r.ToolCallID) resultText := string(r.Result) if resultText == "" || resultText == "null" { @@ -391,7 +395,9 @@ func (r toolResultRaw) toToolResultPart() fantasy.ToolResultPart { message = extracted } return fantasy.ToolResultPart{ - ToolCallID: toolCallID, + ToolCallID: toolCallID, + ProviderExecuted: r.ProviderExecuted, + ProviderOptions: r.providerOptions(logger), Output: fantasy.ToolResultOutputContentError{ Error: xerrors.New(message), }, @@ -399,13 +405,43 @@ func (r toolResultRaw) toToolResultPart() fantasy.ToolResultPart { } return fantasy.ToolResultPart{ - ToolCallID: toolCallID, + ToolCallID: toolCallID, + ProviderExecuted: r.ProviderExecuted, + ProviderOptions: r.providerOptions(logger), Output: fantasy.ToolResultOutputContentText{ Text: resultText, }, } } +// providerOptions deserializes the stored provider metadata +// JSON into a ProviderOptions map using the fantasy type +// registry. Returns nil when no metadata is stored. +func (r toolResultRaw) providerOptions(logger slog.Logger) fantasy.ProviderOptions { + if len(r.ProviderMetadata) == 0 { + return nil + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(r.ProviderMetadata, &raw); err != nil { + logger.Warn(context.Background(), + "failed to unmarshal provider metadata JSON", + slog.F("tool_call_id", r.ToolCallID), + slog.Error(err), + ) + return nil + } + opts, err := fantasy.UnmarshalProviderOptions(raw) + if err != nil { + logger.Warn(context.Background(), + "failed to deserialize provider metadata", + slog.F("tool_call_id", r.ToolCallID), + slog.Error(err), + ) + return nil + } + return opts +} + // extractErrorString pulls the "error" field from a JSON object if // present, returning it as a string. Returns "" if the field is // missing or the input is not an object. @@ -609,12 +645,22 @@ func injectFileID(encoded json.RawMessage, fileID uuid.UUID) (json.RawMessage, e // MarshalToolResult encodes a single tool result for persistence as // an opaque JSON blob. The stored shape is // [{"tool_call_id":…,"tool_name":…,"result":…,"is_error":…}]. -func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isError bool) (pqtype.NullRawMessage, error) { +func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isError bool, providerExecuted bool, providerMetadata fantasy.ProviderMetadata) (pqtype.NullRawMessage, error) { + var metaJSON json.RawMessage + if len(providerMetadata) > 0 { + var err error + metaJSON, err = json.Marshal(providerMetadata) + if err != nil { + return pqtype.NullRawMessage{}, xerrors.Errorf("encode provider metadata: %w", err) + } + } row := toolResultRaw{ - ToolCallID: toolCallID, - ToolName: toolName, - Result: result, - IsError: isError, + ToolCallID: toolCallID, + ToolName: toolName, + Result: result, + IsError: isError, + ProviderExecuted: providerExecuted, + ProviderMetadata: metaJSON, } data, err := json.Marshal([]toolResultRaw{row}) if err != nil { @@ -653,7 +699,7 @@ func MarshalToolResultContent(content fantasy.ToolResultContent) (pqtype.NullRaw result = []byte(`{}`) } - return MarshalToolResult(content.ToolCallID, content.ToolName, result, isError) + return MarshalToolResult(content.ToolCallID, content.ToolName, result, isError, content.ProviderExecuted, content.ProviderMetadata) } // PartFromContent converts fantasy content into a SDK chat message part. @@ -681,17 +727,19 @@ func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart { } case fantasy.ToolCallContent: return codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: value.ToolCallID, - ToolName: value.ToolName, - Args: []byte(value.Input), + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Args: []byte(value.Input), + ProviderExecuted: value.ProviderExecuted, } case *fantasy.ToolCallContent: return codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: value.ToolCallID, - ToolName: value.ToolName, - Args: []byte(value.Input), + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Args: []byte(value.Input), + ProviderExecuted: value.ProviderExecuted, } case fantasy.SourceContent: return codersdk.ChatMessagePart{ @@ -771,7 +819,9 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes result = []byte(`{}`) } - return ToolResultToPart(content.ToolCallID, content.ToolName, result, isError) + part := ToolResultToPart(content.ToolCallID, content.ToolName, result, isError) + part.ProviderExecuted = content.ProviderExecuted + return part } func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message { diff --git a/coderd/chatd/chatprompt/chatprompt_test.go b/coderd/chatd/chatprompt/chatprompt_test.go index 56d3124366..de9de2f961 100644 --- a/coderd/chatd/chatprompt/chatprompt_test.go +++ b/coderd/chatd/chatprompt/chatprompt_test.go @@ -10,6 +10,7 @@ import ( "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/chatd/chatprompt" "github.com/coder/coder/v2/coderd/database" ) @@ -63,6 +64,8 @@ func TestConvertMessages_NormalizesAssistantToolCallInput(t *testing.T) { "execute", json.RawMessage(`{"error":"tool call was interrupted before it produced a result"}`), true, + false, + nil, ) require.NoError(t, err) @@ -134,6 +137,7 @@ func TestConvertMessagesWithFiles_ResolvesFileData(t *testing.T) { }, }, resolver, + slogtest.Make(t, nil), ) require.NoError(t, err) require.Len(t, prompt, 1) @@ -175,6 +179,7 @@ func TestConvertMessagesWithFiles_BackwardCompat(t *testing.T) { }, }, nil, // No resolver. + slogtest.Make(t, nil), ) require.NoError(t, err) require.Len(t, prompt, 1) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 0c9e152ea1..e3c8781140 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1193,11 +1193,12 @@ func chatMessageParts(role string, raw pqtype.NullRawMessage) ([]codersdk.ChatMe parts := make([]codersdk.ChatMessagePart, 0, len(results)) for _, result := range results { parts = append(parts, codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolResult, - ToolCallID: result.ToolCallID, - ToolName: result.ToolName, - Result: result.Result, - IsError: result.IsError, + Type: codersdk.ChatMessagePartTypeToolResult, + ToolCallID: result.ToolCallID, + ToolName: result.ToolName, + Result: result.Result, + IsError: result.IsError, + ProviderExecuted: result.ProviderExecuted, }) } return parts, nil @@ -1251,10 +1252,11 @@ func parseContentBlocks(role string, raw pqtype.NullRawMessage) ([]fantasy.Conte // toolResultRow is used only for extracting top-level fields from // persisted tool result JSON. The result payload is kept as raw JSON. type toolResultRow struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Result json.RawMessage `json:"result"` - IsError bool `json:"is_error,omitempty"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Result json.RawMessage `json:"result"` + IsError bool `json:"is_error,omitempty"` + ProviderExecuted bool `json:"provider_executed,omitempty"` } func parseToolResults(raw pqtype.NullRawMessage) ([]toolResultRow, error) { @@ -1293,17 +1295,19 @@ func contentBlockToPart(block fantasy.Content) codersdk.ChatMessagePart { } case fantasy.ToolCallContent: return codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: value.ToolCallID, - ToolName: value.ToolName, - Args: []byte(value.Input), + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Args: []byte(value.Input), + ProviderExecuted: value.ProviderExecuted, } case *fantasy.ToolCallContent: return codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeToolCall, - ToolCallID: value.ToolCallID, - ToolName: value.ToolName, - Args: []byte(value.Input), + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Args: []byte(value.Input), + ProviderExecuted: value.ProviderExecuted, } case fantasy.SourceContent: return codersdk.ChatMessagePart{ diff --git a/codersdk/chats.go b/codersdk/chats.go index e3fdc9a427..baa16e672b 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -83,22 +83,23 @@ const ( // ChatMessagePart is a structured chunk of a chat message. type ChatMessagePart struct { - Type ChatMessagePartType `json:"type"` - Text string `json:"text,omitempty"` - Signature string `json:"signature,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - ToolName string `json:"tool_name,omitempty"` - Args json.RawMessage `json:"args,omitempty"` - ArgsDelta string `json:"args_delta,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - ResultDelta string `json:"result_delta,omitempty"` - IsError bool `json:"is_error,omitempty"` - SourceID string `json:"source_id,omitempty"` - URL string `json:"url,omitempty"` - Title string `json:"title,omitempty"` - MediaType string `json:"media_type,omitempty"` - Data []byte `json:"data,omitempty"` - FileID uuid.NullUUID `json:"file_id,omitempty" format:"uuid"` + Type ChatMessagePartType `json:"type"` + Text string `json:"text,omitempty"` + Signature string `json:"signature,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + ArgsDelta string `json:"args_delta,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + ResultDelta string `json:"result_delta,omitempty"` + IsError bool `json:"is_error,omitempty"` + ProviderExecuted bool `json:"provider_executed,omitempty"` + SourceID string `json:"source_id,omitempty"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + MediaType string `json:"media_type,omitempty"` + Data []byte `json:"data,omitempty"` + FileID uuid.NullUUID `json:"file_id,omitempty" format:"uuid"` // The following fields are only set when Type is // ChatInputPartTypeFileReference. FileName string `json:"file_name,omitempty"` @@ -313,6 +314,9 @@ type ChatModelOpenAIProviderOptions struct { ServiceTier *string `json:"service_tier,omitempty" description:"Latency tier to use for processing the request"` StructuredOutputs *bool `json:"structured_outputs,omitempty" description:"Whether to enable structured JSON output mode" hidden:"true"` StrictJSONSchema *bool `json:"strict_json_schema,omitempty" description:"Whether to enforce strict adherence to the JSON schema" hidden:"true"` + WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable OpenAI web search tool for grounding responses with real-time information"` + SearchContextSize *string `json:"search_context_size,omitempty" description:"Amount of search context to use" enum:"low,medium,high"` + AllowedDomains []string `json:"allowed_domains,omitempty" description:"Restrict web search to these domains"` } // ChatModelAnthropicThinkingOptions configures Anthropic thinking budget. @@ -326,6 +330,9 @@ type ChatModelAnthropicProviderOptions struct { Thinking *ChatModelAnthropicThinkingOptions `json:"thinking,omitempty" description:"Configuration for extended thinking"` Effort *string `json:"effort,omitempty" description:"Controls the level of reasoning effort" enum:"low,medium,high,max"` DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty" description:"Whether to disable parallel tool execution"` + WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Anthropic web search tool for grounding responses with real-time information"` + AllowedDomains []string `json:"allowed_domains,omitempty" description:"Restrict web search to these domains (cannot be used with blocked_domains)"` + BlockedDomains []string `json:"blocked_domains,omitempty" description:"Block web search on these domains (cannot be used with allowed_domains)"` } // ChatModelGoogleThinkingConfig configures Google thinking behavior. @@ -342,10 +349,11 @@ type ChatModelGoogleSafetySetting struct { // ChatModelGoogleProviderOptions configures Google provider behavior. type ChatModelGoogleProviderOptions struct { - ThinkingConfig *ChatModelGoogleThinkingConfig `json:"thinking_config,omitempty" description:"Configuration for extended thinking"` - CachedContent string `json:"cached_content,omitempty" description:"Resource name of a cached content object" hidden:"true"` - SafetySettings []ChatModelGoogleSafetySetting `json:"safety_settings,omitempty" description:"Safety filtering settings for harmful content categories" hidden:"true"` - Threshold string `json:"threshold,omitempty" hidden:"true"` + ThinkingConfig *ChatModelGoogleThinkingConfig `json:"thinking_config,omitempty" description:"Configuration for extended thinking"` + CachedContent string `json:"cached_content,omitempty" description:"Resource name of a cached content object" hidden:"true"` + SafetySettings []ChatModelGoogleSafetySetting `json:"safety_settings,omitempty" description:"Safety filtering settings for harmful content categories" hidden:"true"` + Threshold string `json:"threshold,omitempty" hidden:"true"` + WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Google Search grounding for real-time information"` } // ChatModelOpenAICompatProviderOptions configures OpenAI-compatible behavior. diff --git a/go.mod b/go.mod index cfb7cb27e7..fb6e0a7b88 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 // 1) Adds thinking effort to Anthropic provider // 2) Downgraded to Go 1.25 due to issue with Windows CI // https://github.com/kylecarbs/fantasy/compare/main...kylecarbs:fantasy:cj/go1.25 -replace charm.land/fantasy => github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f +replace charm.land/fantasy => github.com/kylecarbs/fantasy v0.0.0-20260311152049-8eff3573caec replace github.com/charmbracelet/anthropic-sdk-go => github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab @@ -91,7 +91,7 @@ require ( github.com/aquasecurity/trivy-iac v0.8.0 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible - github.com/aws/smithy-go v1.24.1 + github.com/aws/smithy-go v1.24.2 github.com/bramvdbogaerde/go-scp v1.6.0 github.com/briandowns/spinner v1.23.0 github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 @@ -214,7 +214,7 @@ require ( golang.org/x/text v0.34.0 golang.org/x/tools v0.42.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da - google.golang.org/api v0.267.0 + google.golang.org/api v0.269.0 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 @@ -260,20 +260,20 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.9 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.3 + github.com/aws/aws-sdk-go-v2/config v1.32.11 + github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -456,7 +456,7 @@ require ( google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/ini.v1 v1.67.1 // indirect howett.net/plist v1.0.0 // indirect kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect @@ -468,6 +468,7 @@ require github.com/coder/clistat v1.2.1 require github.com/SherClockHolmes/webpush-go v1.4.0 require ( + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect @@ -518,18 +519,17 @@ require ( github.com/aquasecurity/jfather v0.0.8 // indirect github.com/aquasecurity/trivy v0.61.1-0.20250407075540-f1329c7ea1aa // indirect github.com/aquasecurity/trivy-checks v1.12.2-0.20251219190323-79d27547baf5 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect + github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect github.com/charmbracelet/x/json v0.2.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect @@ -611,7 +611,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect - google.golang.org/genai v1.47.0 // indirect + google.golang.org/genai v1.49.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect mvdan.cc/gofumpt v0.8.0 // indirect diff --git a/go.sum b/go.sum index e4fa460cae..16fc5c4d77 100644 --- a/go.sum +++ b/go.sum @@ -162,48 +162,48 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= -github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= -github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= -github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= +github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= +github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs= +github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2 h1:QbFjOdplTkOgviHNKyTW/TZpvIYhD6lqEc3tkIvqMoQ= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2/go.mod h1:d0pTYUeTv5/tPSlbPZZQSqssM158jZBs02jx2LDslM8= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV8Mw4TCa8IyNO0as= github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= -github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -797,8 +797,8 @@ github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:5UMY github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= -github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f h1:8Xa42VBnKO+83zEhFBZF2eKVOl1pCLXxeCXOllCSVB4= -github.com/kylecarbs/fantasy v0.0.0-20260225152134-45ae0791c21f/go.mod h1:Ro/xRzPtU/+P178CMzGBn8XsEFp/X/nr7meQ1pjX7eI= +github.com/kylecarbs/fantasy v0.0.0-20260311152049-8eff3573caec h1:JBaAQYkOre8gAaQZ6SKiQZ2g+em35WxzHTWtTtFDX5M= +github.com/kylecarbs/fantasy v0.0.0-20260311152049-8eff3573caec/go.mod h1:p6cYJVG8D8AC51MgejAKCMu0myRyQ+vKLuoJQ3biaXo= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e h1:OP0ZMFeZkUnOzTFRfpuK3m7Kp4fNvC6qN+exwj7aI4M= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -1495,19 +1495,19 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= -google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= +google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo= -google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0= +google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index f26afe1767..995eabb4a6 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -86,6 +86,30 @@ "description": "Whether to disable parallel tool execution", "required": false, "input_type": "select" + }, + { + "json_name": "web_search_enabled", + "go_name": "WebSearchEnabled", + "type": "boolean", + "description": "Enable Anthropic web search tool for grounding responses with real-time information", + "required": false, + "input_type": "select" + }, + { + "json_name": "allowed_domains", + "go_name": "AllowedDomains", + "type": "array", + "description": "Restrict web search to these domains (cannot be used with blocked_domains)", + "required": false, + "input_type": "json" + }, + { + "json_name": "blocked_domains", + "go_name": "BlockedDomains", + "type": "array", + "description": "Block web search on these domains (cannot be used with allowed_domains)", + "required": false, + "input_type": "json" } ] }, @@ -132,6 +156,14 @@ "required": false, "input_type": "input", "hidden": true + }, + { + "json_name": "web_search_enabled", + "go_name": "WebSearchEnabled", + "type": "boolean", + "description": "Enable Google Search grounding for real-time information", + "required": false, + "input_type": "select" } ] }, @@ -311,6 +343,31 @@ "required": false, "input_type": "select", "hidden": true + }, + { + "json_name": "web_search_enabled", + "go_name": "WebSearchEnabled", + "type": "boolean", + "description": "Enable OpenAI web search tool for grounding responses with real-time information", + "required": false, + "input_type": "select" + }, + { + "json_name": "search_context_size", + "go_name": "SearchContextSize", + "type": "string", + "description": "Amount of search context to use", + "required": false, + "enum": ["low", "medium", "high"], + "input_type": "select" + }, + { + "json_name": "allowed_domains", + "go_name": "AllowedDomains", + "type": "array", + "description": "Restrict web search to these domains", + "required": false, + "input_type": "json" } ] }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1f2fc7e8e8..faafc51ecc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1175,6 +1175,7 @@ export interface ChatMessagePart { readonly result?: Record; readonly result_delta?: string; readonly is_error?: boolean; + readonly provider_executed?: boolean; readonly source_id?: string; readonly url?: string; readonly title?: string; @@ -1248,6 +1249,9 @@ export interface ChatModelAnthropicProviderOptions { readonly thinking?: ChatModelAnthropicThinkingOptions; readonly effort?: string; readonly disable_parallel_tool_use?: boolean; + readonly web_search_enabled?: boolean; + readonly allowed_domains?: readonly string[]; + readonly blocked_domains?: readonly string[]; } // From codersdk/chats.go @@ -1299,6 +1303,7 @@ export interface ChatModelGoogleProviderOptions { readonly cached_content?: string; readonly safety_settings?: readonly ChatModelGoogleSafetySetting[]; readonly threshold?: string; + readonly web_search_enabled?: boolean; } // From codersdk/chats.go @@ -1355,6 +1360,9 @@ export interface ChatModelOpenAIProviderOptions { readonly service_tier?: string; readonly structured_outputs?: boolean; readonly strict_json_schema?: boolean; + readonly web_search_enabled?: boolean; + readonly search_context_size?: string; + readonly allowed_domains?: readonly string[]; } // From codersdk/chats.go diff --git a/site/src/components/ai-elements/tool/WebSearchSources.tsx b/site/src/components/ai-elements/tool/WebSearchSources.tsx new file mode 100644 index 0000000000..681fa3662b --- /dev/null +++ b/site/src/components/ai-elements/tool/WebSearchSources.tsx @@ -0,0 +1,111 @@ +import { ExternalLinkIcon, GlobeIcon } from "lucide-react"; +import { type FC, useMemo } from "react"; +import { cn } from "utils/cn"; +import { ToolCollapsible } from "./ToolCollapsible"; + +interface WebSearchSourcesProps { + sources: Array<{ url: string; title: string }>; +} + +/** + * Renders web search sources as a collapsible tool card, consistent + * with other tool call renderings. The collapsed header shows a globe + * icon and "Searched N sources"; expanding reveals clickable pills. + */ +const WebSearchSources: FC = ({ sources }) => { + // Deduplicate sources by URL, keeping the first occurrence. + const unique = useMemo(() => { + const seen = new Set(); + return sources.filter((s) => { + if (!s.url || seen.has(s.url)) { + return false; + } + seen.add(s.url); + return true; + }); + }, [sources]); + + if (unique.length === 0) { + return null; + } + + const detail = unique.length === 1 ? "1 result" : `${unique.length} results`; + + return ( + 0} + header={ + <> + + + Searched {detail} + + + } + > +
+ {unique.map((source) => ( + + ))} +
+
+ ); +}; + +/** + * A single source citation pill. Shows a favicon from Google's S2 + * service, a truncated title, and an external-link icon on hover. + */ +const SourcePill: FC<{ source: { url: string; title: string } }> = ({ + source, +}) => { + let hostname: string; + try { + hostname = new URL(source.url).hostname; + } catch { + hostname = ""; + } + + const faviconUrl = hostname + ? `https://www.google.com/s2/favicons?domain=${hostname}&sz=16` + : undefined; + + // Use the title if available, otherwise fall back to the hostname. + const label = source.title || hostname || source.url; + + return ( + + {faviconUrl && ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {label} + + + ); +}; + +export default WebSearchSources; diff --git a/site/src/components/ai-elements/tool/index.ts b/site/src/components/ai-elements/tool/index.ts index 865a6403eb..8774da4459 100644 --- a/site/src/components/ai-elements/tool/index.ts +++ b/site/src/components/ai-elements/tool/index.ts @@ -1 +1,2 @@ export { Tool } from "./Tool"; +export { default as WebSearchSources } from "./WebSearchSources"; diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx index 9f83e6754b..1d007b118a 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -7,6 +7,7 @@ import { Shimmer, Tool, } from "components/ai-elements"; +import { WebSearchSources } from "components/ai-elements/tool"; import { FileIcon } from "components/FileIcon/FileIcon"; import { Spinner } from "components/Spinner/Spinner"; import { ChevronDownIcon } from "lucide-react"; @@ -267,6 +268,13 @@ function renderBlockList({ ); } return null; + case "sources": + return ( + + ); default: return null; } @@ -314,8 +322,22 @@ const ChatMessageItem = memo<{ return null; } + // Hide messages that consist entirely of provider-executed + // tool results. The parser skips these parts, so the parsed + // output is empty and would show a "no renderable content" + // fallback. + const parts = message.content ?? []; + if ( + parts.length > 0 && + parts.every((p) => p.type === "tool-result" && p.provider_executed) + ) { + return null; + } + const hasRenderableContent = - parsed.blocks.length > 0 || parsed.tools.length > 0; + parsed.blocks.length > 0 || + parsed.tools.length > 0 || + parsed.sources.length > 0; const conversationItemProps: { role: "user" | "assistant" } = { role: isUser ? "user" : "assistant", }; diff --git a/site/src/pages/AgentsPage/AgentDetail/StreamingOutput.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/StreamingOutput.stories.tsx index 23e3de4b1a..b0de061f18 100644 --- a/site/src/pages/AgentsPage/AgentDetail/StreamingOutput.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/StreamingOutput.stories.tsx @@ -69,6 +69,7 @@ export const StreamingWithText: Story = { ], toolCalls: {}, toolResults: {}, + sources: [], }, streamTools: [], }, @@ -86,6 +87,7 @@ export const StreamingAfterRetry: Story = { ], toolCalls: {}, toolResults: {}, + sources: [], }, streamTools: [], retryState: null, diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts index 09ce73e4dd..e86aa24446 100644 --- a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts @@ -76,6 +76,7 @@ const emptyParsedMessageContent = (): ParsedMessageContent => ({ toolResults: [], tools: [], blocks: [], + sources: [], }); /** Wraps appendTextBlock with newline-joining for complete message blocks. */ @@ -180,6 +181,13 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { } case "tool-call": case "toolcall": { + // Provider-executed tool calls (e.g. web_search) are + // handled by the provider itself — hide them from the + // tool card UI and let the sources component render + // their results. + if (typedBlock.provider_executed) { + break; + } const name = asString(typedBlock.tool_name) || asString(typedBlock.name); const id = @@ -214,6 +222,10 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { } case "tool-result": case "toolresult": { + // Skip synthetic results for provider-executed tools. + if (typedBlock.provider_executed) { + break; + } const name = asString(typedBlock.tool_name) || asString(typedBlock.name); const id = @@ -251,6 +263,33 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { } break; } + case "source": { + const url = asString(typedBlock.url); + const title = asString(typedBlock.title); + if (url) { + const source = { url, title: title || url }; + // Still populate the flat list for backward compat. + if (!parsed.sources.some((s) => s.url === url)) { + parsed.sources.push(source); + } + // Group consecutive sources into a single + // inline block at this position. + const lastBlock = parsed.blocks[parsed.blocks.length - 1]; + if ( + lastBlock && + lastBlock.type === "sources" && + !lastBlock.sources.some((s) => s.url === url) + ) { + lastBlock.sources.push(source); + } else if (!lastBlock || lastBlock.type !== "sources") { + parsed.blocks.push({ + type: "sources", + sources: [source], + }); + } + } + break; + } default: { const text = asString(typedBlock.text); parsed.markdown = appendText(parsed.markdown, text); diff --git a/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts b/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts index cec13e8935..fd3bf59268 100644 --- a/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts +++ b/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts @@ -30,6 +30,7 @@ describe("applyMessagePartToStreamState", () => { blocks: [{ type: "response", text: "Hello" }], toolCalls: {}, toolResults: {}, + sources: [], }; const result = applyMessagePartToStreamState(prev, { type: "text", @@ -254,6 +255,7 @@ describe("buildStreamTools", () => { "tc-1": { id: "tc-1", name: "bash", args: { cmd: "ls" } }, }, toolResults: {}, + sources: [], }; const tools = buildStreamTools(state); expect(tools).toHaveLength(1); @@ -274,6 +276,7 @@ describe("buildStreamTools", () => { isError: false, }, }, + sources: [], }; const tools = buildStreamTools(state); expect(tools[0].status).toBe("completed"); @@ -291,6 +294,7 @@ describe("buildStreamTools", () => { isError: false, }, }, + sources: [], }; const tools = buildStreamTools(state); expect(tools).toHaveLength(1); diff --git a/site/src/pages/AgentsPage/AgentDetail/streamState.ts b/site/src/pages/AgentsPage/AgentDetail/streamState.ts index 20985d714a..29b3af2318 100644 --- a/site/src/pages/AgentsPage/AgentDetail/streamState.ts +++ b/site/src/pages/AgentsPage/AgentDetail/streamState.ts @@ -7,7 +7,7 @@ import { parseToolResultIsError, } from "./messageParsing"; import { mergeStreamPayload } from "./streamingJson"; -import type { MergedTool, StreamState } from "./types"; +import type { MergedTool, RenderBlock, StreamState } from "./types"; let nextFallbackID = 0; @@ -15,6 +15,7 @@ export const createEmptyStreamState = (): StreamState => ({ blocks: [], toolCalls: {}, toolResults: {}, + sources: [], }); /** Streaming variant — uses direct concatenation (the default joinText). */ @@ -57,6 +58,12 @@ export const applyMessagePartToStreamState = ( } case "tool-call": case "toolcall": { + // Provider-executed tool calls (e.g. web_search) are + // handled natively by the provider — skip rendering them + // as tool cards. + if (part.provider_executed) { + return prev; + } const toolName = asString(part.tool_name); const existingByName = Object.values(nextState.toolCalls).find( (call) => call.name === toolName, @@ -89,6 +96,10 @@ export const applyMessagePartToStreamState = ( } case "tool-result": case "toolresult": { + // Skip synthetic results for provider-executed tools. + if (part.provider_executed) { + return prev; + } const toolName = asString(part.tool_name); const existingByName = Object.values(nextState.toolResults).find( (result) => result.name === toolName, @@ -150,6 +161,41 @@ export const applyMessagePartToStreamState = ( ], }; } + case "source": { + const url = asString(part.url); + const title = asString(part.title); + if (!url) { + return prev; + } + const source = { url, title: title || url }; + // Still populate the flat list for backward compat. + if (nextState.sources.some((s) => s.url === url)) { + return prev; + } + const newSources = [...nextState.sources, source]; + // Group consecutive sources into a single inline + // block at the current position in the block list. + const lastBlock = nextState.blocks[nextState.blocks.length - 1]; + let newBlocks: RenderBlock[]; + if (lastBlock && lastBlock.type === "sources") { + // Append to existing sources block. + newBlocks = [...nextState.blocks]; + newBlocks[newBlocks.length - 1] = { + type: "sources", + sources: [...lastBlock.sources, source], + }; + } else { + newBlocks = [ + ...nextState.blocks, + { type: "sources", sources: [source] }, + ]; + } + return { + ...nextState, + sources: newSources, + blocks: newBlocks, + }; + } default: return prev; } diff --git a/site/src/pages/AgentsPage/AgentDetail/types.ts b/site/src/pages/AgentsPage/AgentDetail/types.ts index 9232732147..ffbbcc2cda 100644 --- a/site/src/pages/AgentsPage/AgentDetail/types.ts +++ b/site/src/pages/AgentsPage/AgentDetail/types.ts @@ -49,6 +49,10 @@ export type RenderBlock = endLine: number; content: string; text: string; + } + | { + type: "sources"; + sources: Array<{ url: string; title: string }>; }; export type ParsedMessageContent = { @@ -58,6 +62,7 @@ export type ParsedMessageContent = { toolResults: ParsedToolResult[]; tools: MergedTool[]; blocks: RenderBlock[]; + sources: Array<{ url: string; title: string }>; }; export type ParsedMessageEntry = { @@ -89,4 +94,5 @@ export type StreamState = { blocks: RenderBlock[]; toolCalls: Record; toolResults: Record; + sources: Array<{ url: string; title: string }>; }; diff --git a/site/src/pages/AgentsPage/AgentDetail/useWorkspaceCreationWatcher.test.tsx b/site/src/pages/AgentsPage/AgentDetail/useWorkspaceCreationWatcher.test.tsx index bd1a03aebd..f8458597a2 100644 --- a/site/src/pages/AgentsPage/AgentDetail/useWorkspaceCreationWatcher.test.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/useWorkspaceCreationWatcher.test.tsx @@ -17,6 +17,7 @@ const createStreamState = ( blocks: [], toolCalls, toolResults, + sources: [], }); type MinimalChatStoreState = Pick<