Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions core/config/yaml/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ async function modelConfigToBaseLLM({
baseAgentSystemMessage: model.chatOptions?.baseAgentSystemMessage,
basePlanSystemMessage: model.chatOptions?.basePlanSystemMessage,
baseChatSystemMessage: model.chatOptions?.baseSystemMessage,
toolOverrides: model.chatOptions?.toolOverrides
? Object.entries(model.chatOptions.toolOverrides).map(([name, o]) => ({
name,
...o,
}))
: undefined,
capabilities: {
tools: model.capabilities?.includes("tool_use"),
uploadImage: model.capabilities?.includes("image_input"),
Expand Down
13 changes: 13 additions & 0 deletions core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
DataDestination,
ModelRole,
PromptTemplates,
ToolOverrideConfig,
} from "@continuedev/config-yaml";
import Parser from "web-tree-sitter";
import { CodebaseIndexer } from "./indexing/CodebaseIndexer";
Expand Down Expand Up @@ -694,6 +695,9 @@ export interface LLMOptions {

sourceFile?: string;
isFromAutoDetect?: boolean;

/** Tool overrides for this model */
toolOverrides?: ToolOverride[];
}

type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<
Expand Down Expand Up @@ -1141,6 +1145,15 @@ export interface Tool {
) => ToolPolicy;
}

/**
* Configuration for overriding built-in tool prompts.
* Extends ToolOverrideConfig with required name for array usage.
*/
export type ToolOverride = ToolOverrideConfig & {
/** Tool name to override (matches function.name, e.g., "read_file") */
name: string;
};

interface ToolChoice {
type: "function";
function: {
Expand Down
31 changes: 29 additions & 2 deletions core/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
RequestOptions,
TabAutocompleteOptions,
TemplateType,
ToolOverride,
Usage,
} from "../index.js";
import { isLemonadeInstalled } from "../util/lemonadeHelper.js";
Expand Down Expand Up @@ -65,6 +66,8 @@ import {
toCompleteBody,
toFimBody,
} from "./openaiTypeConverters.js";
import { applyToolOverrides } from "../tools/applyToolOverrides.js";

export class LLMError extends Error {
constructor(
message: string,
Expand Down Expand Up @@ -196,6 +199,9 @@ export abstract class BaseLLM implements ILLM {

isFromAutoDetect?: boolean;

/** Tool overrides for this model */
toolOverrides?: ToolOverride[];

lastRequestId: string | undefined;

private _llmOptions: LLMOptions;
Expand Down Expand Up @@ -303,6 +309,7 @@ export abstract class BaseLLM implements ILLM {
this.autocompleteOptions = options.autocompleteOptions;
this.sourceFile = options.sourceFile;
this.isFromAutoDetect = options.isFromAutoDetect;
this.toolOverrides = options.toolOverrides;
}

get contextLength() {
Expand Down Expand Up @@ -1111,8 +1118,28 @@ export abstract class BaseLLM implements ILLM {
messageOptions?: MessageOption,
): AsyncGenerator<ChatMessage, PromptLog> {
this.lastRequestId = undefined;

// Apply per-model tool overrides if configured
let effectiveTools = options.tools;
if (this.toolOverrides?.length && options.tools?.length) {
const { tools: overriddenTools, errors } = applyToolOverrides(
options.tools,
this.toolOverrides,
);
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;
Expand All @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions core/tools/applyToolOverrides.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
185 changes: 185 additions & 0 deletions core/tools/applyToolOverrides.vitest.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
7 changes: 6 additions & 1 deletion extensions/cli/src/stream/streamChatResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { pruneLastMessage } from "../compaction.js";
import { services } from "../services/index.js";
import { posthogService } from "../telemetry/posthogService.js";
import { telemetryService } from "../telemetry/telemetryService.js";
import { applyChatCompletionToolOverrides } from "../tools/applyToolOverrides.js";
import { ToolCall } from "../tools/index.js";
import {
chatCompletionStreamWithBackoff,
Expand Down Expand Up @@ -460,7 +461,11 @@ export async function streamChatResponse(
);

// Recompute tools on each iteration to handle mode changes during streaming
const tools = await getRequestTools(isHeadless);
const rawTools = await getRequestTools(isHeadless);
const tools = applyChatCompletionToolOverrides(
rawTools,
model.chatOptions?.toolOverrides,
);

// Pre-API auto-compaction checkpoint (now includes tools)
const preCompactionResult = await handlePreApiCompaction(chatHistory, {
Expand Down
Loading
Loading