Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 65 additions & 17 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1222,13 +1222,21 @@ func (p *Server) SendMessage(
}

// Update MCP server IDs on the chat when explicitly provided.
// Explore child chats keep the spawn-time snapshot immutable.
if opts.MCPServerIDs != nil {
lockedChat, err = tx.UpdateChatMCPServerIDs(ctx, database.UpdateChatMCPServerIDsParams{
ID: opts.ChatID,
MCPServerIDs: *opts.MCPServerIDs,
})
if err != nil {
return xerrors.Errorf("update chat mcp server ids: %w", err)
if isExploreSubagentMode(lockedChat.Mode) {
p.logger.Warn(ctx,
"ignoring explore subagent mcp server ids update, snapshot is immutable after spawn",
slog.F("chat_id", opts.ChatID),
)
} else {
lockedChat, err = tx.UpdateChatMCPServerIDs(ctx, database.UpdateChatMCPServerIDsParams{
ID: opts.ChatID,
MCPServerIDs: *opts.MCPServerIDs,
})
if err != nil {
return xerrors.Errorf("update chat mcp server ids: %w", err)
}
}
}

Expand Down Expand Up @@ -5215,6 +5223,9 @@ func isExploreSubagentMode(mode database.NullChatMode) bool {
return mode.Valid && mode.ChatMode == database.ChatModeExplore
}

// filterExternalMCPConfigsForTurn returns the external MCP server configs
// visible on the current turn. Explore children snapshot this filtered set at
// spawn time so later model overrides cannot widen the external-tool boundary.
func filterExternalMCPConfigsForTurn(
configs []database.MCPServerConfig,
mode database.NullChatPlanMode,
Expand Down Expand Up @@ -5341,6 +5352,15 @@ func allowedExploreToolNames(allTools []fantasy.AgentTool) []string {
name := tool.Info().Name
if builtinExplorePolicy[name] {
toolNames = append(toolNames, name)
continue
}
// External MCP tools pass through here. They were snapshot-filtered
// at spawn time on chat.MCPServerIDs. WorkspaceMCPTool does not
// implement MCPToolIdentifier, so workspace tools are excluded
// here too, in addition to the structural exclusion in runChat
// tool assembly.
if _, ok := tool.(mcpclient.MCPToolIdentifier); ok {
toolNames = append(toolNames, name)
}
}
return toolNames
Expand Down Expand Up @@ -5698,11 +5718,25 @@ func (p *Server) runChat(
isPlanModeTurn := currentPlanMode.Valid && currentPlanMode.ChatPlanMode == database.ChatPlanModePlan
isExploreSubagent := isExploreSubagentMode(chat.Mode)
isRootChat := !chat.ParentChatID.Valid
mcpConnectConfigs, approvedPlanMCPConfigIDs := filterExternalMCPConfigsForTurn(
var mcpConnectConfigs []database.MCPServerConfig
var approvedPlanMCPConfigIDs map[uuid.UUID]struct{}
// Explore subagents rely on the immutable spawn-time snapshot
// persisted in chat.MCPServerIDs. SendMessage cannot mutate that
// snapshot, so no runtime re-filter against parent state is needed.
// The child's persisted set is authoritative.
mcpConnectConfigs, approvedPlanMCPConfigIDs = filterExternalMCPConfigsForTurn(
mcpConfigs,
currentPlanMode,
chat.ParentChatID,
)
if isExploreSubagent && isRootChat {
// Root Explore chats stay builtin-only per the accepted plan, so
// strip any persisted external MCP configs at runtime regardless of
// what's on the chat row. Explore children get their snapshot via
// the spawn-time inheritance path and are handled below.
mcpConnectConfigs = nil
approvedPlanMCPConfigIDs = map[uuid.UUID]struct{}{}
}
planModeInstructions := p.loadPlanModeInstructions(ctx, currentPlanMode, logger)

chainInfo := resolveChainMode(messages)
Expand Down Expand Up @@ -6416,14 +6450,12 @@ func (p *Server) runChat(
builtinToolNames[t.Info().Name] = true
}

// Append external and workspace MCP tools after the built-ins so the
// LLM sees them as additional capabilities. Explore subagents keep
// the narrower built-in-only boundary from main. Root plan mode gets
// only approved external MCP tools because mcpConnectConfigs was
// pre-filtered above, and filterToolsForTurn removes any remaining
// plan-mode ineligible tools from the assembled set.
// Append external MCP tools from the chat's persisted snapshot after the
// built-ins so the LLM sees them as additional capabilities. Explore chats
// trust only the persisted MCPServerIDs snapshot, and workspace-local MCP
// tools stay unavailable to Explore chats.
tools = append(tools, mcpTools...)
if !isExploreSubagent {
tools = append(tools, mcpTools...)
tools = append(tools, workspaceMCPTools...)
}
tools = filterToolsForTurn(
Expand All @@ -6449,11 +6481,23 @@ func (p *Server) runChat(
return result, err
}

// Build provider-native tools (e.g., web search) based on
// the model configuration.
// Build provider-native tools (e.g. web search) based on the
// current model configuration. Root Explore chats stay builtin-only per
// the accepted plan, so delegated Explore children are the only Explore
// chats that can inherit web_search. Write-style provider tools stay
// blocked for all Explore chats.
var providerTools []chatloop.ProviderTool
if !isPlanModeTurn && !isExploreSubagent && callConfig.ProviderOptions != nil {
if !isPlanModeTurn && callConfig.ProviderOptions != nil {
providerTools = buildProviderTools(model.Provider(), callConfig.ProviderOptions)
if isExploreSubagent {
if !chat.ParentChatID.Valid {
providerTools = nil
} else {
providerTools = slices.DeleteFunc(providerTools, func(tool chatloop.ProviderTool) bool {
return tool.Definition.GetName() != "web_search"
})
}
}
}

if !isPlanModeTurn && !isExploreSubagent && isComputerUse {
Expand Down Expand Up @@ -6689,6 +6733,10 @@ func (p *Server) runChat(
func buildProviderTools(_ string, options *codersdk.ChatModelProviderOptions) []chatloop.ProviderTool {
var tools []chatloop.ProviderTool

if options == nil {
return nil
}

if options.Anthropic != nil && options.Anthropic.WebSearchEnabled != nil && *options.Anthropic.WebSearchEnabled {
tools = append(tools, chatloop.ProviderTool{
Definition: anthropic.WebSearchTool(&anthropic.WebSearchToolOptions{
Expand Down
40 changes: 18 additions & 22 deletions coderd/x/chatd/chatd_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,36 +300,32 @@ func TestActiveToolNamesForTurn(t *testing.T) {
func TestAllowedExploreToolNames(t *testing.T) {
t.Parallel()

makeTools := func(names ...string) []fantasy.AgentTool {
tools := make([]fantasy.AgentTool, 0, len(names))
for _, name := range names {
tools = append(tools, newTestAgentTool(name))
}
return tools
}

got := allowedExploreToolNames(makeTools(
"read_file",
"write_file",
"edit_files",
"execute",
"process_output",
"process_list",
"process_signal",
"spawn_agent",
"wait_agent",
"read_skill",
"read_skill_file",
"ask_user_question",
))
externalConfigID := uuid.New()
got := allowedExploreToolNames([]fantasy.AgentTool{
newTestAgentTool("read_file"),
newTestAgentTool("write_file"),
newTestMCPAgentTool("external-mcp__echo", externalConfigID),
newTestAgentTool("workspace-mcp__echo"),
newTestAgentTool("execute"),
newTestAgentTool("process_output"),
newTestAgentTool("process_list"),
newTestAgentTool("process_signal"),
newTestAgentTool("spawn_agent"),
newTestAgentTool("wait_agent"),
newTestAgentTool("read_skill"),
newTestAgentTool("read_skill_file"),
newTestAgentTool("ask_user_question"),
})

require.Equal(t, []string{
"read_file",
"external-mcp__echo",
"execute",
"process_output",
"read_skill",
"read_skill_file",
}, got)
require.NotContains(t, got, "workspace-mcp__echo")
}

func TestAllowedBehaviorToolNames(t *testing.T) {
Expand Down
Loading
Loading