diff --git a/core/config/yaml/models.ts b/core/config/yaml/models.ts index 05fa488532a..bfcebdaca4d 100644 --- a/core/config/yaml/models.ts +++ b/core/config/yaml/models.ts @@ -3,12 +3,28 @@ import { ModelConfig, } from "@continuedev/config-yaml"; -import { ContinueConfig, ILLMLogger, LLMOptions } from "../.."; +import { ContinueConfig, ILLMLogger, LLMOptions, ToolOverride } from "../.."; import { BaseLLM } from "../../llm"; import { LLMClasses } from "../../llm/llms"; const AUTODETECT = "AUTODETECT"; +/** + * Converts YAML record format { toolName: {...} } to array format [{ name: toolName, ...}] + * for use with applyToolOverrides + */ +function convertToolPromptOverridesToArray( + record: Record> | undefined, +): ToolOverride[] | undefined { + if (!record) { + return undefined; + } + return Object.entries(record).map(([name, override]) => ({ + name, + ...override, + })); +} + function getModelClass( model: ModelConfig, ): (typeof LLMClasses)[number] | undefined { @@ -67,6 +83,9 @@ async function modelConfigToBaseLLM({ baseAgentSystemMessage: model.chatOptions?.baseAgentSystemMessage, basePlanSystemMessage: model.chatOptions?.basePlanSystemMessage, baseChatSystemMessage: model.chatOptions?.baseSystemMessage, + toolPromptOverrides: convertToolPromptOverridesToArray( + model.chatOptions?.toolPromptOverrides, + ), capabilities: { tools: model.capabilities?.includes("tool_use"), uploadImage: model.capabilities?.includes("image_input"), diff --git a/core/index.d.ts b/core/index.d.ts index 197b5516d0c..b133a035cbb 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -694,6 +694,9 @@ export interface LLMOptions { sourceFile?: string; isFromAutoDetect?: boolean; + + /** Tool prompt overrides for this model */ + toolPromptOverrides?: ToolOverride[]; } type RequireAtLeastOne = Pick< @@ -1139,6 +1142,30 @@ export interface Tool { ) => ToolPolicy; } +/** + * Configuration for overriding built-in tool prompts. + * Allows customization of tool descriptions and behavior per model. + */ +export interface ToolOverride { + /** Tool name to override (matches function.name, e.g., "read_file", "run_terminal_command") */ + name: string; + /** Override the tool's description shown to the LLM */ + description?: string; + /** Override the display title shown in UI */ + displayTitle?: string; + /** Override the action phrases */ + wouldLikeTo?: string; + isCurrently?: string; + hasAlready?: string; + /** Override system message description for non-native tool calling */ + systemMessageDescription?: { + prefix?: string; + exampleArgs?: Array<[string, string | number]>; + }; + /** Set to true to disable this tool */ + disabled?: boolean; +} + interface ToolChoice { type: "function"; function: { diff --git a/core/llm/index.ts b/core/llm/index.ts index ceea1153dcd..c0c6e08e486 100644 --- a/core/llm/index.ts +++ b/core/llm/index.ts @@ -28,6 +28,7 @@ import { RequestOptions, TabAutocompleteOptions, TemplateType, + ToolOverride, Usage, } from "../index.js"; import { isLemonadeInstalled } from "../util/lemonadeHelper.js"; @@ -65,6 +66,8 @@ import { toCompleteBody, toFimBody, } from "./openaiTypeConverters.js"; +import { applyToolOverrides } from "../tools/applyToolOverrides.js"; + export class LLMError extends Error { constructor( message: string, @@ -196,6 +199,9 @@ export abstract class BaseLLM implements ILLM { isFromAutoDetect?: boolean; + /** Tool prompt overrides for this model */ + toolPromptOverrides?: ToolOverride[]; + lastRequestId: string | undefined; private _llmOptions: LLMOptions; @@ -303,6 +309,7 @@ export abstract class BaseLLM implements ILLM { this.autocompleteOptions = options.autocompleteOptions; this.sourceFile = options.sourceFile; this.isFromAutoDetect = options.isFromAutoDetect; + this.toolPromptOverrides = options.toolPromptOverrides; } get contextLength() { @@ -1111,8 +1118,28 @@ export abstract class BaseLLM implements ILLM { messageOptions?: MessageOption, ): AsyncGenerator { this.lastRequestId = undefined; + + // Apply per-model tool prompt overrides if configured + let effectiveTools = options.tools; + if (this.toolPromptOverrides?.length && options.tools?.length) { + const { tools: overriddenTools, errors } = applyToolOverrides( + options.tools, + this.toolPromptOverrides, + ); + effectiveTools = overriddenTools; + // Log any warnings for unknown tool names + for (const error of errors) { + if (!error.fatal) { + console.warn(`Tool override warning: ${error.message}`); + } + } + } + + // Use effectiveTools for the rest of this method + const optionsWithOverrides = { ...options, tools: effectiveTools }; + let { completionOptions, logEnabled } = - this._parseCompletionOptions(options); + this._parseCompletionOptions(optionsWithOverrides); const interaction = logEnabled ? this.logger?.createInteractionLog() : undefined; @@ -1130,7 +1157,7 @@ export abstract class BaseLLM implements ILLM { knownContextLength: this._contextLength, maxTokens: completionOptions.maxTokens ?? DEFAULT_MAX_TOKENS, supportsImages: this.supportsImages(), - tools: options.tools, + tools: optionsWithOverrides.tools, }); messages = compiledChatMessages; diff --git a/core/tools/applyToolOverrides.ts b/core/tools/applyToolOverrides.ts new file mode 100644 index 00000000000..52c7b94cb1f --- /dev/null +++ b/core/tools/applyToolOverrides.ts @@ -0,0 +1,69 @@ +import { ConfigValidationError } from "@continuedev/config-yaml"; +import { Tool, ToolOverride } from ".."; + +export interface ApplyToolOverridesResult { + tools: Tool[]; + errors: ConfigValidationError[]; +} + +/** + * Applies tool overrides from config to the list of tools. + * Overrides can modify tool descriptions, display titles, action phrases, + * system message descriptions, or disable tools entirely. + */ +export function applyToolOverrides( + tools: Tool[], + overrides: ToolOverride[] | undefined, +): ApplyToolOverridesResult { + if (!overrides?.length) { + return { tools, errors: [] }; + } + + const errors: ConfigValidationError[] = []; + const toolsByName = new Map(tools.map((t) => [t.function.name, t])); + + for (const override of overrides) { + const tool = toolsByName.get(override.name); + + if (!tool) { + errors.push({ + fatal: false, + message: `Tool override "${override.name}" does not match any known tool. Available tools: ${Array.from(toolsByName.keys()).join(", ")}`, + }); + continue; + } + + if (override.disabled) { + toolsByName.delete(override.name); + continue; + } + + const updatedTool: Tool = { + ...tool, + function: { + ...tool.function, + description: override.description ?? tool.function.description, + }, + displayTitle: override.displayTitle ?? tool.displayTitle, + wouldLikeTo: override.wouldLikeTo ?? tool.wouldLikeTo, + isCurrently: override.isCurrently ?? tool.isCurrently, + hasAlready: override.hasAlready ?? tool.hasAlready, + }; + + if (override.systemMessageDescription) { + updatedTool.systemMessageDescription = { + prefix: + override.systemMessageDescription.prefix ?? + tool.systemMessageDescription?.prefix ?? + "", + exampleArgs: + override.systemMessageDescription.exampleArgs ?? + tool.systemMessageDescription?.exampleArgs, + }; + } + + toolsByName.set(override.name, updatedTool); + } + + return { tools: Array.from(toolsByName.values()), errors }; +} diff --git a/core/tools/applyToolOverrides.vitest.ts b/core/tools/applyToolOverrides.vitest.ts new file mode 100644 index 00000000000..190590980bb --- /dev/null +++ b/core/tools/applyToolOverrides.vitest.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { Tool, ToolOverride } from ".."; +import { applyToolOverrides } from "./applyToolOverrides"; + +const mockTool = (name: string, description: string): Tool => ({ + type: "function", + displayTitle: name, + readonly: true, + group: "test", + function: { name, description }, +}); + +describe("applyToolOverrides", () => { + it("should return tools unchanged when no overrides provided", () => { + const tools = [mockTool("read_file", "Read a file")]; + const result = applyToolOverrides(tools, undefined); + expect(result.tools).toEqual(tools); + expect(result.errors).toHaveLength(0); + }); + + it("should return tools unchanged when empty overrides array provided", () => { + const tools = [mockTool("read_file", "Read a file")]; + const result = applyToolOverrides(tools, []); + expect(result.tools).toEqual(tools); + expect(result.errors).toHaveLength(0); + }); + + it("should override description when specified", () => { + const tools = [mockTool("read_file", "Original description")]; + const overrides: ToolOverride[] = [ + { name: "read_file", description: "New description" }, + ]; + const result = applyToolOverrides(tools, overrides); + expect(result.tools[0].function.description).toBe("New description"); + expect(result.errors).toHaveLength(0); + }); + + it("should override displayTitle when specified", () => { + const tools = [mockTool("read_file", "Read a file")]; + const overrides: ToolOverride[] = [ + { name: "read_file", displayTitle: "Custom Read File" }, + ]; + const result = applyToolOverrides(tools, overrides); + expect(result.tools[0].displayTitle).toBe("Custom Read File"); + }); + + it("should override action phrases when specified", () => { + const tools = [mockTool("read_file", "Read a file")]; + tools[0].wouldLikeTo = "read {{{ filepath }}}"; + tools[0].isCurrently = "reading {{{ filepath }}}"; + tools[0].hasAlready = "read {{{ filepath }}}"; + + const overrides: ToolOverride[] = [ + { + name: "read_file", + wouldLikeTo: "open {{{ filepath }}}", + isCurrently: "opening {{{ filepath }}}", + hasAlready: "opened {{{ filepath }}}", + }, + ]; + const result = applyToolOverrides(tools, overrides); + expect(result.tools[0].wouldLikeTo).toBe("open {{{ filepath }}}"); + expect(result.tools[0].isCurrently).toBe("opening {{{ filepath }}}"); + expect(result.tools[0].hasAlready).toBe("opened {{{ filepath }}}"); + }); + + it("should disable tools when disabled: true", () => { + const tools = [ + mockTool("read_file", "Read"), + mockTool("write_file", "Write"), + ]; + const overrides: ToolOverride[] = [{ name: "read_file", disabled: true }]; + const result = applyToolOverrides(tools, overrides); + expect(result.tools).toHaveLength(1); + expect(result.tools[0].function.name).toBe("write_file"); + expect(result.errors).toHaveLength(0); + }); + + it("should warn when override references unknown tool", () => { + const tools = [mockTool("read_file", "Read")]; + const overrides: ToolOverride[] = [ + { name: "unknown_tool", description: "test" }, + ]; + const result = applyToolOverrides(tools, overrides); + expect(result.tools).toHaveLength(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain("unknown_tool"); + expect(result.errors[0].fatal).toBe(false); + }); + + it("should preserve unmodified fields", () => { + const tools = [mockTool("read_file", "Original")]; + tools[0].readonly = true; + tools[0].group = "Built-In"; + + const overrides: ToolOverride[] = [ + { name: "read_file", description: "New description" }, + ]; + const result = applyToolOverrides(tools, overrides); + expect(result.tools[0].readonly).toBe(true); + expect(result.tools[0].group).toBe("Built-In"); + expect(result.tools[0].displayTitle).toBe("read_file"); + }); + + it("should override systemMessageDescription", () => { + const tools = [mockTool("read_file", "Read")]; + tools[0].systemMessageDescription = { + prefix: "old prefix", + exampleArgs: [["filepath", "/old/path"]], + }; + + const overrides: ToolOverride[] = [ + { + name: "read_file", + systemMessageDescription: { + prefix: "new prefix", + exampleArgs: [["filepath", "/new/path"]], + }, + }, + ]; + const result = applyToolOverrides(tools, overrides); + expect(result.tools[0].systemMessageDescription?.prefix).toBe("new prefix"); + expect(result.tools[0].systemMessageDescription?.exampleArgs).toEqual([ + ["filepath", "/new/path"], + ]); + }); + + it("should partially override systemMessageDescription", () => { + const tools = [mockTool("read_file", "Read")]; + tools[0].systemMessageDescription = { + prefix: "old prefix", + exampleArgs: [["filepath", "/old/path"]], + }; + + const overrides: ToolOverride[] = [ + { + name: "read_file", + systemMessageDescription: { + prefix: "new prefix", + // exampleArgs not specified - should preserve original + }, + }, + ]; + const result = applyToolOverrides(tools, overrides); + expect(result.tools[0].systemMessageDescription?.prefix).toBe("new prefix"); + expect(result.tools[0].systemMessageDescription?.exampleArgs).toEqual([ + ["filepath", "/old/path"], + ]); + }); + + it("should apply multiple overrides", () => { + const tools = [ + mockTool("read_file", "Read"), + mockTool("write_file", "Write"), + mockTool("delete_file", "Delete"), + ]; + + const overrides: ToolOverride[] = [ + { name: "read_file", description: "Custom read" }, + { name: "write_file", disabled: true }, + { name: "delete_file", displayTitle: "Remove File" }, + ]; + + const result = applyToolOverrides(tools, overrides); + expect(result.tools).toHaveLength(2); + expect(result.tools[0].function.description).toBe("Custom read"); + expect(result.tools[1].displayTitle).toBe("Remove File"); + expect(result.errors).toHaveLength(0); + }); + + it("should not mutate original tools array", () => { + const tools = [mockTool("read_file", "Original")]; + const originalDescription = tools[0].function.description; + + const overrides: ToolOverride[] = [ + { name: "read_file", description: "New description" }, + ]; + const result = applyToolOverrides(tools, overrides); + + // Original should be unchanged + expect(tools[0].function.description).toBe(originalDescription); + // Result should have new description + expect(result.tools[0].function.description).toBe("New description"); + }); +}); diff --git a/packages/config-yaml/src/schemas/models.ts b/packages/config-yaml/src/schemas/models.ts index 9f5a9e96e94..1c69f51f294 100644 --- a/packages/config-yaml/src/schemas/models.ts +++ b/packages/config-yaml/src/schemas/models.ts @@ -86,10 +86,38 @@ export const embedOptionsSchema = z.object({ }); export type EmbedOptions = z.infer; +/** + * Schema for overriding a tool's system message description. + * Used for models that don't support native tool calling. + */ +export const systemMessageDescriptionOverrideSchema = z.object({ + prefix: z.string().optional(), + exampleArgs: z + .array(z.tuple([z.string(), z.union([z.string(), z.number()])])) + .optional(), +}); + +/** + * Schema for overriding built-in tool prompts. + * Allows customization of tool descriptions and behavior per model. + */ +export const toolOverrideSchema = z.object({ + description: z.string().optional(), + displayTitle: z.string().optional(), + wouldLikeTo: z.string().optional(), + isCurrently: z.string().optional(), + hasAlready: z.string().optional(), + systemMessageDescription: systemMessageDescriptionOverrideSchema.optional(), + disabled: z.boolean().optional(), +}); +export type ToolOverrideConfig = z.infer; + export const chatOptionsSchema = z.object({ baseSystemMessage: z.string().optional(), baseAgentSystemMessage: z.string().optional(), basePlanSystemMessage: z.string().optional(), + /** Tool prompt overrides keyed by tool name (e.g., "run_terminal_command") */ + toolPromptOverrides: z.record(z.string(), toolOverrideSchema).optional(), }); export type ChatOptions = z.infer;