Skip to content

Commit 057f2ee

Browse files
Tajudeenclaude
andcommitted
fix(web_search): gate web tools on model SIZE, not coder-ness (fixes Auto->llama3)
Testing "check online ... SpaceX IPO" in AUTO mode surfaced a distinct bug from the qwen2.5-coder path. Auto resolved to a capable GENERAL model (llama3:8b), which was DENIED web_search and fell back to stale training knowledge: Search the web "SpaceX IPO date" Error: The web_search tool isn't available for this model. Use one of: read_file, ... -> "SpaceX has not gone public through an IPO..." (WRONG) Root cause: web tools (web_search/browse_url) were gated on isCapableLocalCoder -- a CODER >=7B (codingModelScoreBonus>=25 AND >=7B). llama3:8b is capable but not a coder, so it got the COMPACT toolset (no web). Web search is a GENERAL capability, not coding-specific -- any sufficiently large local model should have it. Fix: new size-only gate isCapableLocalModel (>=7B, or unnumbered/flagship tag), used for the web-tool toolset decision at BOTH the prompt catalog (convertToLLMMessageService) AND the execution chokepoint (chatThreadService). Renamed the threaded toolset boolean isCapableLocalCoder -> isCapableLocalModel through prompts.ts/chatThreadService/convertToLLMMessageService for honesty; isCapableLocalCoder the FUNCTION stays (onboarding/routing still want a coder). Live-verified over CDP (fresh thread each, real chat): - Auto -> qwen2.5-coder:7b (7.6B): gate true, answers "June 12, 2026" correctly. - llama3:latest (8.0B, GENERAL, the original failure): gate now true at BOTH the chokepoint and the prompt; searches "SpaceX IPO date" (5 results) and answers "Based on the search results ... IPO on June 12, 2026 ... ticker SPCX." CORRECT. - llama3.2:3b (3.2B): gate correctly FALSE -- small models stay web-less. 909->913 node tests (+4 isCapableLocalModel cases incl. the llama3:8b regression), tsgo 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fcfd3ab commit 057f2ee

6 files changed

Lines changed: 92 additions & 41 deletions

File tree

‎src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { URI } from '../../../../base/common/uri.js';
1212
import { Emitter, Event } from '../../../../base/common/event.js';
1313
import { ILLMMessageService } from '../common/sendLLMMessageService.js';
1414
import { chat_userMessageContent, isABuiltinToolName, builtinToolNames, localToolsetFor, READ_ONLY_SUBAGENT_TOOLS } from '../common/prompt/prompts.js';
15-
import { isCapableLocalCoder } from '../common/routing/codingModelScore.js';
15+
import { isCapableLocalModel } from '../common/routing/codingModelScore.js';
1616
import { AnthropicReasoning, getErrorMessage, RawToolCallObj, RawToolParamsObj } from '../common/sendLLMMessageTypes.js';
1717
import { generateUuid } from '../../../../base/common/uuid.js';
1818
import { ChatMode, FeatureName, ModelSelection, ModelSelectionOptions, ProviderName, localProviderNames, isAutoModelSelection } from '../common/cortexideSettingsTypes.js';
@@ -2344,7 +2344,7 @@ Output ONLY the JSON, no other text. Start with { and end with }.`
23442344
opts: { preapproved: true, unvalidatedToolParams: RawToolParamsObj, validatedParams: ToolCallParams<ToolName> } | { preapproved: false, unvalidatedToolParams: RawToolParamsObj },
23452345
isLocal: boolean = false,
23462346
chatMode: ChatMode = 'agent',
2347-
isCapableLocalCoder: boolean = false,
2347+
isCapableLocalModel: boolean = false,
23482348
): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean, completionSignaled?: boolean }> => {
23492349

23502350
// compute these below
@@ -2633,7 +2633,7 @@ Output ONLY the JSON, no other text. Start with { and end with }.`
26332633
// Hard curation for local/weak models: even if a non-curated tool (web_search, terminals, ...)
26342634
// slipped past the catalog and was parsed, do NOT execute it — return a recoverable result so a
26352635
// weak model can't get distracted by tools it shouldn't use.
2636-
const localSet = localToolsetFor(isCapableLocalCoder)
2636+
const localSet = localToolsetFor(isCapableLocalModel)
26372637
if (isLocal && !(localSet as Set<string>).has(toolName)) {
26382638
throw new Error(`The ${toolName} tool isn't available for this model. Use one of: ${[...localSet].join(', ')}.`)
26392639
}
@@ -2680,7 +2680,7 @@ Output ONLY the JSON, no other text. Start with { and end with }.`
26802680
// instead of the misleading raw "MCP tool X not found".
26812681
// List the tools the model was actually OFFERED (curated for local models), so this
26822682
// error doesn't re-introduce the tools curation deliberately hid from a weak model.
2683-
const offered = isLocal ? [...localToolsetFor(isCapableLocalCoder)] : [...builtinToolNames, ...(mcpTools?.map(t => t.name) ?? [])]
2683+
const offered = isLocal ? [...localToolsetFor(isCapableLocalModel)] : [...builtinToolNames, ...(mcpTools?.map(t => t.name) ?? [])]
26842684
throw new Error(`No tool named "${toolName}". Use one of the available tools: ${offered.join(', ')}`)
26852685
}
26862686

@@ -3265,7 +3265,7 @@ Output ONLY the JSON, no other text. Start with { and end with }.`
32653265
// with cloud caps and the local tool-curation gate disabled — findings #5/#6.)
32663266
let chatMode: ChatMode = userChatMode
32673267
let isLocalModel = false
3268-
let isCapableLocalCoderModel = false
3268+
let isCapableLocalModelFlag = false
32693269
let maxAgentIterations = MAX_AGENT_LOOP_ITERATIONS
32703270
let maxConsecutiveToolErrors = MAX_CONSECUTIVE_TOOL_ERRORS
32713271
const recomputeModelState = (m: ModelSelection | null) => {
@@ -3278,10 +3278,12 @@ Output ONLY the JSON, no other text. Start with { and end with }.`
32783278
maxLocalConsecutiveToolErrors: MAX_LOCAL_CONSECUTIVE_TOOL_ERRORS,
32793279
})
32803280
isLocalModel = caps.isLocalModel
3281-
// A capable local coder (>=7B) also gets the web tools (web_search/browse_url) at both the prompt
3282-
// catalog and the execution chokepoint, so "check online" works locally instead of hallucinating.
3283-
isCapableLocalCoderModel = caps.isLocalModel && !!m && m.providerName !== 'auto'
3284-
&& isCapableLocalCoder(m.modelName.toLowerCase(), this._settingsService.state.settingsOfProvider[m.providerName]?.models?.find((mm: { modelName: string; parameterSize?: string }) => mm.modelName === m.modelName)?.parameterSize)
3281+
// A capable local model (>=7B -- coder OR general, e.g. llama3:8b that Auto may resolve to) also
3282+
// gets the web tools (web_search/browse_url) at both the prompt catalog and the execution
3283+
// chokepoint, so "check online" works locally instead of hallucinating. Web search is a general
3284+
// capability, gated on SIZE not coder-ness (isCapableLocalModel).
3285+
isCapableLocalModelFlag = caps.isLocalModel && !!m && m.providerName !== 'auto'
3286+
&& isCapableLocalModel(m.modelName.toLowerCase(), this._settingsService.state.settingsOfProvider[m.providerName]?.models?.find((mm: { modelName: string; parameterSize?: string }) => mm.modelName === m.modelName)?.parameterSize)
32853287
maxAgentIterations = caps.maxAgentIterations
32863288
maxConsecutiveToolErrors = caps.maxConsecutiveToolErrors
32873289
}
@@ -4626,7 +4628,7 @@ Output ONLY the JSON, no other text. Start with { and end with }.`
46264628
{ preapproved: false, unvalidatedToolParams: toolParams },
46274629
isLocalModel, // enforce local-model tool curation on synthesized calls too (else a local model can run a non-curated tool it can't recover from)
46284630
chatMode, // dispatch-level mode enforcement (read-only modes block writes/terminal even for synthesized calls)
4629-
isCapableLocalCoderModel, // a capable local coder (>=7B) is allowed the web tools at the chokepoint too
4631+
isCapableLocalModelFlag, // a capable local model (>=7B, coder or general) is allowed the web tools at the chokepoint too
46304632
)
46314633

46324634
if (interrupted) {
@@ -4711,7 +4713,7 @@ Output ONLY the JSON, no other text. Start with { and end with }.`
47114713
{ preapproved: false, unvalidatedToolParams: toolParams },
47124714
isLocalModel, // keep local-model curation consistent across all tool-dispatch paths
47134715
chatMode, // dispatch-level mode enforcement (read-only modes block writes/terminal even for synthesized calls)
4714-
isCapableLocalCoderModel, // a capable local coder (>=7B) is allowed the web tools at the chokepoint too
4716+
isCapableLocalModelFlag, // a capable local model (>=7B, coder or general) is allowed the web tools at the chokepoint too
47154717
)
47164718

47174719
if (interrupted) {
@@ -4835,7 +4837,7 @@ Output ONLY the JSON, no other text. Start with { and end with }.`
48354837
const mcpTools = this._mcpService.getMCPTools()
48364838
const mcpTool = mcpTools?.find(t => t.name === toolCall.name)
48374839

4838-
const { awaitingUserApproval, interrupted, completionSignaled } = await this._runToolCall(threadId, toolCall.name, toolCall.id, mcpTool?.mcpServerName, { preapproved: false, unvalidatedToolParams: toolCall.rawParams }, isLocalModel, chatMode, isCapableLocalCoderModel)
4840+
const { awaitingUserApproval, interrupted, completionSignaled } = await this._runToolCall(threadId, toolCall.name, toolCall.id, mcpTool?.mcpServerName, { preapproved: false, unvalidatedToolParams: toolCall.rawParams }, isLocalModel, chatMode, isCapableLocalModelFlag)
48394841
if (interrupted) {
48404842
this._setStreamState(threadId, undefined)
48414843
if (activePlanTracking?.currentStep) {

‎src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function uint8ArrayToBase64(data: Uint8Array): string {
5555
}
5656
import { getIsReasoningEnabledState, getReservedOutputTokenSpace, getModelCapabilities } from '../common/modelCapabilities.js';
5757
import { reParsedToolXMLString, chat_systemMessage, chat_systemMessage_local } from '../common/prompt/prompts.js';
58-
import { isCapableLocalCoder } from '../common/routing/codingModelScore.js';
58+
import { isCapableLocalModel } from '../common/routing/codingModelScore.js';
5959
import { AnthropicLLMChatMessage, AnthropicReasoning, GeminiLLMChatMessage, LLMChatMessage, LLMFIMMessage, OpenAILLMChatMessage, RawToolParamsObj } from '../common/sendLLMMessageTypes.js';
6060
import { ICortexideSettingsService } from '../common/cortexideSettingsService.js';
6161
import { ChatMode, FeatureName, ModelSelection, ProviderName } from '../common/cortexideSettingsTypes.js';
@@ -1541,13 +1541,15 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess
15411541

15421542
// For local models, use minimal system message template instead of truncating
15431543
const isLocal = isLocalProvider(validProviderName, this.cortexideSettingsService.state.settingsOfProvider)
1544-
// A capable local coder (>=7B) additionally gets the web tools (so "check online" actually works);
1545-
// small local models stay on the compact set. Param size comes from the provider's reported model
1546-
// details (ollama details.parameter_size), same source the router uses.
1544+
// A capable local model (>=7B -- coder OR general) additionally gets the web tools (so "check online"
1545+
// actually works); small local models stay on the compact set. Param size comes from the provider's
1546+
// reported model details (ollama details.parameter_size), same source the router uses.
15471547
const realParamSizeLocal: string | undefined = isLocal
15481548
? this.cortexideSettingsService.state.settingsOfProvider[validProviderName]?.models?.find((m: { modelName: string; parameterSize?: string }) => m.modelName === modelName)?.parameterSize
15491549
: undefined
1550-
const isCapableLocalCoderModel = isLocal && isCapableLocalCoder(modelName.toLowerCase(), realParamSizeLocal)
1550+
// Web tools are gated on model CAPABILITY (>=7B), not coder-ness -- a capable general model
1551+
// (e.g. llama3:8b, which Auto may resolve to) should also get web_search, not just coders.
1552+
const isCapableLocalModelFlag = isLocal && isCapableLocalModel(modelName.toLowerCase(), realParamSizeLocal)
15511553

15521554
let systemMessage: string
15531555
if (disableSystemMessage) {
@@ -1600,7 +1602,7 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess
16001602

16011603
const activeFileURILocal = this.editorService.activeEditor?.resource;
16021604
const projectRulesLocal = this._getCombinedAIInstructions(activeFileURILocal) || undefined;
1603-
systemMessage = chat_systemMessage_local({ workspaceFolders, openedURIs, directoryStr, activeURI, persistentTerminalIDs, chatMode, mcpTools, includeXMLToolDefinitions, relevantMemories, projectRules: projectRulesLocal, subagentSystemPrompt, allowedToolNames, isCapableLocalCoder: isCapableLocalCoderModel })
1605+
systemMessage = chat_systemMessage_local({ workspaceFolders, openedURIs, directoryStr, activeURI, persistentTerminalIDs, chatMode, mcpTools, includeXMLToolDefinitions, relevantMemories, projectRules: projectRulesLocal, subagentSystemPrompt, allowedToolNames, isCapableLocalModel: isCapableLocalModelFlag })
16041606
} else {
16051607
// Use full system message for cloud models
16061608
systemMessage = await this._generateChatMessagesSystemMessage(chatMode, specialToolFormat, subagentSystemPrompt, allowedToolNames)

‎src/vs/workbench/contrib/cortexide/common/prompt/prompts.ts‎

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -556,15 +556,17 @@ export const COMPACT_LOCAL_TOOLSET = new Set<BuiltinToolName>([
556556
'todo_write', 'attempt_completion', 'run_command',
557557
])
558558

559-
// A CAPABLE local coder (>=7B, e.g. qwen2.5-coder:7b) additionally gets the web tools, so an explicit
560-
// "check online" request actually goes online instead of falling back to a codebase search and then
561-
// hallucinating. Small local models stay on COMPACT_LOCAL_TOOLSET (they tend to misuse web tools). The
562-
// >=7B gate is isCapableLocalCoder (common/routing/codingModelScore.ts).
559+
// A CAPABLE local model (>=7B, e.g. qwen2.5-coder:7b OR a general model like llama3:8b) additionally
560+
// gets the web tools, so an explicit "check online" request actually goes online instead of falling
561+
// back to a codebase search / stale training knowledge and then hallucinating. Web search is a general
562+
// capability, NOT coder-specific -- the gate is SIZE (isCapableLocalModel), so Auto resolving to a
563+
// capable general model still gets web access. Small local models (<=3B) stay on COMPACT_LOCAL_TOOLSET
564+
// (they tend to misuse web tools). Gate: isCapableLocalModel (common/routing/codingModelScore.ts).
563565
export const CAPABLE_LOCAL_TOOLSET = new Set<BuiltinToolName>([...COMPACT_LOCAL_TOOLSET, 'web_search', 'browse_url'])
564566

565-
/** The local-model toolset for a given capability: capable coders also get web tools. */
566-
export const localToolsetFor = (isCapableLocalCoder: boolean | undefined): Set<BuiltinToolName> =>
567-
isCapableLocalCoder ? CAPABLE_LOCAL_TOOLSET : COMPACT_LOCAL_TOOLSET
567+
/** The local-model toolset for a given capability: capable (>=7B) models also get the web tools. */
568+
export const localToolsetFor = (isCapableLocalModel: boolean | undefined): Set<BuiltinToolName> =>
569+
isCapableLocalModel ? CAPABLE_LOCAL_TOOLSET : COMPACT_LOCAL_TOOLSET
568570

569571
// Read-only builtin tools a PARALLEL sub-agent is restricted to (run_parallel_subagents). No edits,
570572
// no run_command, no terminals — so N can run concurrently with zero file-system collision risk.
@@ -575,7 +577,7 @@ export const READ_ONLY_SUBAGENT_TOOLS: string[] = [
575577
'go_to_definition', 'find_references', 'search_symbols', 'attempt_completion',
576578
]
577579

578-
export const availableTools = (chatMode: ChatMode | null, mcpTools: InternalToolInfo[] | undefined, opts?: { isLocal?: boolean, isCapableLocalCoder?: boolean, allowedToolNames?: string[] }) => {
580+
export const availableTools = (chatMode: ChatMode | null, mcpTools: InternalToolInfo[] | undefined, opts?: { isLocal?: boolean, isCapableLocalModel?: boolean, allowedToolNames?: string[] }) => {
579581

580582
let builtinToolNames: BuiltinToolName[] | undefined = chatMode === 'normal' ? undefined
581583
: chatMode === 'gather' ? (Object.keys(builtinTools) as BuiltinToolName[]).filter(toolName =>
@@ -587,7 +589,7 @@ export const availableTools = (chatMode: ChatMode | null, mcpTools: InternalTool
587589
// Weak/local models get a curated subset (and no MCP) so they can't hallucinate/misuse the
588590
// long tail of tools (persistent terminals, web, refactors). See COMPACT_LOCAL_TOOLSET.
589591
if (opts?.isLocal && builtinToolNames) {
590-
const localSet = localToolsetFor(opts.isCapableLocalCoder)
592+
const localSet = localToolsetFor(opts.isCapableLocalModel)
591593
builtinToolNames = builtinToolNames.filter(toolName => localSet.has(toolName))
592594
}
593595

@@ -637,8 +639,8 @@ export const reParsedToolXMLString = (toolName: ToolName, toolParams: RawToolPar
637639

638640
/* We expect tools to come at the end - not a hard limit, but that's just how we process them, and the flow makes more sense that way. */
639641
// - You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them.
640-
const systemToolsXMLPrompt = (chatMode: ChatMode, mcpTools: InternalToolInfo[] | undefined, isLocal?: boolean, allowedToolNames?: string[], isCapableLocalCoder?: boolean) => {
641-
const tools = availableTools(chatMode, mcpTools, { isLocal, isCapableLocalCoder, allowedToolNames })
642+
const systemToolsXMLPrompt = (chatMode: ChatMode, mcpTools: InternalToolInfo[] | undefined, isLocal?: boolean, allowedToolNames?: string[], isCapableLocalModel?: boolean) => {
643+
const tools = availableTools(chatMode, mcpTools, { isLocal, isCapableLocalModel, allowedToolNames })
642644
if (!tools || tools.length === 0) return null
643645

644646
const toolXMLDefinitions = (`\
@@ -840,7 +842,7 @@ ${toolDefinitions}
840842

841843
// Minimal chat system message for local models (drastically reduced)
842844
// Used for local models to minimize token usage and latency
843-
export const chat_systemMessage_local = ({ workspaceFolders, openedURIs, activeURI, chatMode: mode, includeXMLToolDefinitions, relevantMemories, mcpTools, projectRules, subagentSystemPrompt, allowedToolNames, isCapableLocalCoder }: { workspaceFolders: string[], directoryStr: string, openedURIs: string[], activeURI: string | undefined, persistentTerminalIDs: string[], chatMode: ChatMode, mcpTools: InternalToolInfo[] | undefined, includeXMLToolDefinitions: boolean, relevantMemories?: string, projectRules?: string, subagentSystemPrompt?: string, allowedToolNames?: string[], isCapableLocalCoder?: boolean }) => {
845+
export const chat_systemMessage_local = ({ workspaceFolders, openedURIs, activeURI, chatMode: mode, includeXMLToolDefinitions, relevantMemories, mcpTools, projectRules, subagentSystemPrompt, allowedToolNames, isCapableLocalModel }: { workspaceFolders: string[], directoryStr: string, openedURIs: string[], activeURI: string | undefined, persistentTerminalIDs: string[], chatMode: ChatMode, mcpTools: InternalToolInfo[] | undefined, includeXMLToolDefinitions: boolean, relevantMemories?: string, projectRules?: string, subagentSystemPrompt?: string, allowedToolNames?: string[], isCapableLocalModel?: boolean }) => {
844846
const header = (mode === 'agent' || mode === 'plan')
845847
? 'Coding agent. Use tools for actions.'
846848
: mode === 'gather'
@@ -850,13 +852,13 @@ export const chat_systemMessage_local = ({ workspaceFolders, openedURIs, activeU
850852
const sysInfo = `System: ${os} | Today: ${new Date().toDateString()}\nWorkspace: ${workspaceFolders.join(', ') || 'none'}\nActive: ${activeURI || 'none'}\nOpen: ${openedURIs.slice(0, 3).join(', ') || 'none'}${openedURIs.length > 3 ? '...' : ''}`
851853

852854
// Local/weak model -> curated tool subset; capable coders (>=7B) also get the web tools.
853-
const toolDefinitions = includeXMLToolDefinitions ? systemToolsXMLPrompt(mode, mcpTools, true, allowedToolNames, isCapableLocalCoder) : null
855+
const toolDefinitions = includeXMLToolDefinitions ? systemToolsXMLPrompt(mode, mcpTools, true, allowedToolNames, isCapableLocalModel) : null
854856

855857
const details: string[] = []
856858
if (mode === 'agent' || mode === 'plan') {
857-
// Only claim web access when the web tools are actually offered (capable coders); otherwise a small
858-
// model is told it can browse but has no tool, and it fabricates an answer.
859-
details.push(isCapableLocalCoder
859+
// Only claim web access when the web tools are actually offered (capable >=7B models); otherwise a
860+
// small model is told it can browse but has no tool, and it fabricates an answer.
861+
details.push(isCapableLocalModel
860862
? 'Use tools to read/edit files, run commands, or fetch current/web info (web_search/browse_url). Answer general-knowledge or conceptual questions directly, without tools.'
861863
: 'Use tools to read/edit files and run commands. You do NOT have web access; if asked to check online or look up current info, say you cannot (suggest switching to a cloud model). Answer general-knowledge or conceptual questions directly, without tools.')
862864
// Anti-hallucination guard: never invent facts to fill a gap.

‎src/vs/workbench/contrib/cortexide/common/routing/codingModelScore.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,22 @@ export function isCapableLocalCoder(modelNameLower: string, realParamSize?: stri
111111
return params >= 7;
112112
}
113113

114+
/**
115+
* Is this LOCAL model capable enough (by SIZE alone) to be offered the WEB tools (web_search,
116+
* browse_url)? Unlike isCapableLocalCoder this does NOT require a coder -- web search is a general
117+
* capability, so any sufficiently large local model (>= 7B, or an unnumbered/flagship ":latest" tag
118+
* whose real size we don't have -> assume capable) qualifies. Small/weak models (<= ~3B) still get
119+
* only the COMPACT toolset (no web) because they fumble the agentic loop.
120+
*
121+
* Fixes Auto resolving to a capable GENERAL model (e.g. llama3:8b) which was then denied web_search
122+
* by the coder-only gate and answered "SpaceX has not gone public" from stale training knowledge.
123+
*/
124+
export function isCapableLocalModel(modelNameLower: string, realParamSize?: string): boolean {
125+
const params = parseParamSizeBillions(realParamSize) ?? parseParamSizeBillions(modelNameLower);
126+
if (params == null) { return true; } // unnumbered/flagship (":latest") -> assume capable
127+
return params >= 7;
128+
}
129+
114130
/**
115131
* Pick the most capable coder from a list of model NAMES (tags) for a LOCAL provider, reusing the
116132
* same coder + size signal the router uses. Prefers a code-tuned name, breaks ties by larger param

0 commit comments

Comments
 (0)