Skip to content
Open
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
47 changes: 47 additions & 0 deletions src/api/providers/__tests__/base-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ModelInfo } from "@roo-code/types"

import { BaseProvider } from "../base-provider"
import type { ApiStream } from "../../transform/stream"
import executeCommand, { createExecuteCommandTool } from "../../../core/prompts/tools/native-tools/execute_command"

// Create a concrete implementation for testing
class TestProvider extends BaseProvider {
Expand Down Expand Up @@ -41,6 +42,52 @@ describe("BaseProvider", () => {
})

describe("convertToolSchemaForOpenAI", () => {
it("preserves nullable source properties while converting nested schemas", () => {
const schema = {
type: "object",
properties: {
name: { type: ["string", "null"] },
value: { type: ["string", "number", "null"] },
settings: {
type: ["object", "null"],
properties: { timeout: { type: ["number", "null"] } },
},
items: {
type: ["array", "null"],
items: { type: "object", properties: { cwd: { type: ["string", "null"] } } },
},
},
}
const original = structuredClone(schema)

const result = provider.testConvertToolSchemaForOpenAI(schema)

expect(schema).toEqual(original)
expect(result.properties).toMatchObject({
name: { type: "string" },
value: { type: ["string", "number"] },
settings: { type: "object", properties: { timeout: { type: "number" } } },
items: { type: "array", items: { properties: { cwd: { type: "string" } } } },
})
})

it("preserves command schemas for a later NanoGPT request after strict conversion", () => {
const original = structuredClone(executeCommand)
const earlierNonStrict = createExecuteCommandTool({ strict: false })
provider.testConvertToolsForOpenAI([executeCommand])
const laterNonStrict = createExecuteCommandTool({ strict: false })

expect(executeCommand).toEqual(original)
for (const tool of [earlierNonStrict, laterNonStrict]) {
expect(tool.function.strict).toBe(false)
expect(tool.function.parameters?.required).toEqual(["command"])
expect(tool.function.parameters?.properties).toMatchObject({
cwd: { type: ["string", "null"] },
timeout: { type: ["number", "null"] },
})
}
})

it("should add additionalProperties: false to object schemas", () => {
const schema = {
type: "object",
Expand Down
70 changes: 70 additions & 0 deletions src/api/providers/__tests__/nanogpt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { nanoGptDefaultModelId, providerIdentifiers } from "@roo-code/types"
import { buildApiHandler } from "../../index"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
import { createReadFileTool } from "../../../core/prompts/tools/native-tools/read_file"
import { createExecuteCommandTool } from "../../../core/prompts/tools/native-tools/execute_command"
import { NanoGptHandler } from "../nanogpt"
import { getModels } from "../fetchers/modelCache"

Expand Down Expand Up @@ -62,6 +63,75 @@ describe("NanoGptHandler", () => {
expect(buildApiHandler({ apiProvider: providerIdentifiers.nanogpt })).toBeInstanceOf(NanoGptHandler)
})

it("disables strict generation without relaxing required fields in a supplied strict schema", async () => {
const tool = createExecuteCommandTool()
await collectStream(
new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages, {
taskId: "test-command-schema",
tools: [tool],
}),
)
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tools: [
expect.objectContaining({
function: expect.objectContaining({
strict: false,
parameters: expect.objectContaining({ required: ["command", "cwd", "timeout"] }),
}),
}),
],
}),
expect.anything(),
)
expect(tool.function.strict).toBe(true)
})

it.each([undefined, null, 30])("preserves command arguments with timeout %s", async (timeout) => {
const args = JSON.stringify({
command: "printf test",
...(timeout === undefined ? {} : { cwd: null, timeout }),
})
mockCreate.mockResolvedValue(
asyncStreamFrom([
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call-command",
function: {
name: "execute_command",
arguments: args,
},
},
],
},
},
],
},
]),
)
const tool = createExecuteCommandTool({ strict: false })
const chunks = await collectStream(
new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages, {
taskId: "test-command-arguments",
tools: [tool],
tool_choice: "auto",
}),
)
expect(mockCreate.mock.calls[0][0].tools[0].function).toMatchObject({
strict: false,
parameters: { required: ["command"] },
})
expect(chunks).toEqual([
{ type: "tool_call_partial", index: 0, id: "call-command", name: "execute_command", arguments: args },
])
expect(tool.function.parameters?.required).toEqual(["command"])
})

it("keeps the canonical model ID while applying request-only routing", async () => {
const handler = new NanoGptHandler({ nanoGptModelId: "model:thinking", nanoGptRoutingPreference: "fast" })
await collectStream(handler.createMessage("system", messages))
Expand Down
5 changes: 3 additions & 2 deletions src/api/providers/base-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,13 @@ export abstract class BaseProvider implements ApiHandler {
// Recursively process nested objects and convert nullable types
const newProps = { ...result.properties }
for (const key of allKeys) {
const prop = newProps[key]
let prop = newProps[key]

// Handle nullable types by removing null
if (prop && Array.isArray(prop.type) && prop.type.includes("null")) {
const nonNullTypes = prop.type.filter((t: string) => t !== "null")
prop.type = nonNullTypes.length === 1 ? nonNullTypes[0] : nonNullTypes
prop = { ...prop, type: nonNullTypes.length === 1 ? nonNullTypes[0] : nonNullTypes }
newProps[key] = prop
}

// Recursively process nested objects
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@ import { NativeToolCallParser } from "../NativeToolCallParser"

describe("NativeToolCallParser", () => {
describe("parseToolCall", () => {
describe("execute_command optional arguments", () => {
it.each([undefined, null, 30])("preserves an explicit cwd with timeout %s", (timeout) => {
const args = { command: "printf test", cwd: "/tmp/workspace", timeout }
const result = NativeToolCallParser.parseToolCall({
id: "call-command-cwd",
name: "execute_command",
arguments: JSON.stringify(args),
})
expect(result).toMatchObject({
type: "tool_use",
name: "execute_command",
nativeArgs: args,
})
})

it.each([undefined, null, 30])("accepts a finalized command with timeout %s", (timeout) => {
const args = { command: "printf test", ...(timeout === undefined ? {} : { cwd: null, timeout }) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const result = NativeToolCallParser.parseToolCall({
id: "call-command",
name: "execute_command",
arguments: JSON.stringify(args),
})
expect(result).toMatchObject({
type: "tool_use",
name: "execute_command",
nativeArgs: { command: args.command, cwd: args.cwd, timeout },
})
})
})

describe("read_file tool", () => {
it("should parse minimal single-file read_file args", () => {
const toolCall = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import executeCommand, { createExecuteCommandTool } from "../execute_command"
import { getNativeTools, nativeTools } from ".."

describe("execute_command schema", () => {
it("preserves the strict default for existing integrations", () => {
expect(createExecuteCommandTool()).toEqual(executeCommand)
expect(executeCommand.function.strict).toBe(true)
expect(executeCommand.function.parameters.required).toEqual(["command", "cwd", "timeout"])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

it.each([
{ name: "getNativeTools()", tools: () => getNativeTools() },
{ name: "nativeTools", tools: () => nativeTools },
])("preserves the strict command schema in $name", ({ tools }) => {
const command = tools().find((tool) => tool.type === "function" && tool.function.name === "execute_command")
expect(command).toEqual(executeCommand)
expect(command).toMatchObject({
function: {
strict: true,
parameters: { required: ["command", "cwd", "timeout"] },
},
})
})

it("requires only command for non-strict generation while retaining optional field types", () => {
const original = structuredClone(executeCommand)
const tool = createExecuteCommandTool({ strict: false })
expect(tool.function.strict).toBe(false)
expect(tool.function.parameters).toEqual({ ...original.function.parameters, required: ["command"] })
expect(tool.function.parameters?.properties).toMatchObject({
command: { type: "string" },
cwd: { type: ["string", "null"] },
timeout: { type: ["number", "null"] },
})
expect(executeCommand).toEqual(original)
})

it("does not share required arrays across generated schemas", () => {
const first = createExecuteCommandTool({ strict: false })
const second = createExecuteCommandTool({ strict: false })
expect(first.function.parameters?.required).not.toBe(second.function.parameters?.required)
expect(second.function.parameters?.required).toEqual(["command"])
expect(executeCommand.function.parameters.required).toEqual(["command", "cwd", "timeout"])
})
})
25 changes: 23 additions & 2 deletions src/core/prompts/tools/native-tools/execute_command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@

const TIMEOUT_PARAMETER_DESCRIPTION = `Timeout in seconds. When exceeded, the command continues running in the background and output collected so far is returned. Use this for long-running processes like dev servers, file watchers, or any command that may not exit on its own`

export default {
const REQUIRED_COMMAND_PARAMETERS = ["command"] as const

const executeCommand = {
type: "function",
function: {
name: "execute_command",
Expand All @@ -47,8 +49,27 @@
description: TIMEOUT_PARAMETER_DESCRIPTION,
},
},
required: ["command", "cwd", "timeout"],
required: [...REQUIRED_COMMAND_PARAMETERS, "cwd", "timeout"],

Check warning on line 52 in src/core/prompts/tools/native-tools/execute_command.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/prompts/tools/native-tools/execute_command.ts:52: 3 mutation test gaps; example: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

export function createExecuteCommandTool({
strict = true,
}: { strict?: boolean } = {}): OpenAI.Chat.ChatCompletionFunctionTool {
return {
...executeCommand,
function: {
...executeCommand.function,
strict,
parameters: {
...executeCommand.function.parameters,
// Strict generation requires all fields; the executor only requires command.
required: strict ? [...executeCommand.function.parameters.required] : [...REQUIRED_COMMAND_PARAMETERS],
},
},
}
}

export default executeCommand
8 changes: 5 additions & 3 deletions src/core/prompts/tools/native-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import attemptCompletion from "./attempt_completion"
import codebaseSearch from "./codebase_search"
import editTool from "./edit"
import executeCommand from "./execute_command"
import executeCommand, { createExecuteCommandTool } from "./execute_command"
import generateImage from "./generate_image"
import listFiles from "./list_files"
import newTask from "./new_task"
Expand All @@ -31,6 +31,8 @@
export interface NativeToolsOptions {
/** Whether the model supports image processing (default: false) */
supportsImages?: boolean
/** Keep strict required-nullable command fields unless the integration opts out. */
executeCommandStrict?: boolean
}

/**
Expand All @@ -40,7 +42,7 @@
* @returns Array of native tool definitions
*/
export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] {
const { supportsImages = false } = options
const { supportsImages = false, executeCommandStrict = true } = options

Check warning on line 45 in src/core/prompts/tools/native-tools/index.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/prompts/tools/native-tools/index.ts:45: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.

const readFileOptions: ReadFileToolOptions = {
supportsImages,
Expand All @@ -53,7 +55,7 @@
askFollowupQuestion,
attemptCompletion,
codebaseSearch,
executeCommand,
executeCommandStrict ? executeCommand : createExecuteCommandTool({ strict: false }),
generateImage,
listFiles,
newTask,
Expand Down
Loading
Loading