Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5fc2700
feat(read_file): implement CodexCLI-inspired slice/indentation readin…
hannesrudolph Dec 21, 2025
00e62bf
i18n: add read file tool strings
hannesrudolph Dec 21, 2025
823bd2a
fix(read_file): address PR review feedback
hannesrudolph Dec 22, 2025
cd60bc4
fix(read_file): normalize indentation config keys and pagination meta…
hannesrudolph Dec 22, 2025
c903656
fix(read_file): make indentation offset beyond EOF return empty page
hannesrudolph Dec 22, 2025
e072ffc
fix(read_file): empty files should not report hasMoreBefore: true in …
hannesrudolph Dec 22, 2025
d2122e6
fix: update readFileTool tests to use native format instead of XML
hannesrudolph Dec 23, 2025
e098a86
fix: remove unused FileEntry.limit from types and parser
hannesrudolph Dec 23, 2025
9154c06
fix: update type import from ReadFileToolOptions to CreateReadFileToo…
hannesrudolph Jan 7, 2026
58e9f26
docs: clarify limit is not exposed to models, controlled by maxReadFi…
hannesrudolph Jan 7, 2026
26890a5
test: update read_file.spec.ts tests for new offset/mode/indentation API
hannesrudolph Jan 7, 2026
06e5cff
fix: add missing mock methods to presentAssistantMessage-custom-tool …
hannesrudolph Jan 7, 2026
e184ca6
fix: resolve conflicts and address review comments regarding FileEntr…
hannesrudolph Jan 14, 2026
c6b4d1a
fix: update FALLBACK_LIMIT to 2000 lines
hannesrudolph Jan 14, 2026
cb75197
fix: align read_file tool with plan spec
hannesrudolph Jan 15, 2026
95a2390
fix: ensure truncatedByLimit is only true when content is actually ex…
hannesrudolph Jan 15, 2026
dd5670d
Fix strict tool schemas for nullable objects
hannesrudolph Jan 17, 2026
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
31 changes: 27 additions & 4 deletions packages/types/src/tool-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,37 @@
* Tool parameter type definitions for native protocol
*/

export interface LineRange {
start: number
end: number
/**
* Configuration for indentation-aware block extraction
*/
export interface IndentationConfig {
/** The line to anchor the block expansion from (defaults to offset) */
anchorLine?: number
/** Maximum indentation depth to collect; 0 = unlimited */
maxLevels?: number
/** Whether to include sibling blocks at same indentation level */
includeSiblings?: boolean
/** Whether to include comment headers above the anchor block */
includeHeader?: boolean
/** Hard cap on returned lines (defaults to limit) */
maxLines?: number
}

/**
* Read mode for file content extraction
*/
export type ReadMode = "slice" | "indentation"

export interface FileEntry {
path: string
lineRanges?: LineRange[]
/** 1-indexed line number to start reading from (default: 1) */
offset?: number
/** Maximum lines to return (default: value of maxReadFileLine setting) */
limit?: number
/** Reading mode: "slice" for simple reading, "indentation" for smart block extraction */
mode?: ReadMode
/** Configuration for indentation mode */
indentation?: IndentationConfig
}

export interface Coordinate {
Expand Down
19 changes: 19 additions & 0 deletions src/api/providers/__tests__/base-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,25 @@ describe("BaseProvider", () => {
expect(result.additionalProperties).toBe(false)
expect(result.required).toEqual([])
})

it("should inject required for nullable object schemas (type: ['object','null'])", () => {
const schema = {
type: "object",
properties: {
indentation: {
type: ["object", "null"],
properties: {
anchorLine: { type: ["integer", "null"] },
maxLevels: { type: ["integer", "null"] },
},
additionalProperties: false,
},
},
}

const result = provider.testConvertToolSchemaForOpenAI(schema)
expect(result.properties.indentation.required).toEqual(["anchorLine", "maxLevels"])
})
})

describe("convertToolsForOpenAI", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,75 @@ describe("OpenAiCodexHandler native tool calls", () => {
name: "attempt_completion",
})
})

it("normalizes nullable object schemas for strict tools (read_file indentation.anchorLine regression)", async () => {
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")

let capturedBody: any
;(handler as any).client = {
responses: {
create: vi.fn().mockImplementation(async (body: any) => {
capturedBody = body
return {
async *[Symbol.asyncIterator]() {
yield {
type: "response.done",
response: {
output: [{ type: "message", content: [{ type: "output_text", text: "ok" }] }],
usage: { input_tokens: 1, output_tokens: 1 },
},
}
},
}
}),
},
}

const readFileLikeTool = {
type: "function" as const,
function: {
name: "read_file",
description: "Read files",
parameters: {
type: "object",
properties: {
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
indentation: {
type: ["object", "null"],
properties: {
anchorLine: { type: ["integer", "null"] },
maxLevels: { type: ["integer", "null"] },
},
additionalProperties: false,
},
},
},
},
},
},
},
}

const stream = handler.createMessage("system", [{ role: "user", content: "hello" } as any], {
taskId: "t",
toolProtocol: "native",
tools: [readFileLikeTool as any],
})
for await (const _ of stream) {
// consume
}

const tool = capturedBody.tools?.[0]
expect(tool).toBeDefined()
expect(tool.strict).toBe(true)
const indentation = tool.parameters.properties.files.items.properties.indentation
// Critical: nullable-object schemas must still have required containing every key in properties.
expect(indentation.required).toEqual(["anchorLine", "maxLevels"])
})
})
77 changes: 77 additions & 0 deletions src/api/providers/__tests__/openai-native-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,83 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => {
expect(tool.parameters.required).toEqual(["path", "encoding"]) // Should have all properties as required
})

it("should inject required for nullable object schemas (read_file indentation.anchorLine regression)", async () => {
let capturedRequestBody: any

const handler = new OpenAiNativeHandler({
openAiNativeApiKey: "test-key",
apiModelId: "gpt-4o",
} as ApiHandlerOptions)

// Mock the responses API call
const mockClient = {
responses: {
create: vi.fn().mockImplementation((body: any) => {
capturedRequestBody = body
return {
[Symbol.asyncIterator]: async function* () {
yield {
type: "response.done",
response: {
output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }],
usage: { input_tokens: 10, output_tokens: 5 },
},
}
},
}
}),
},
}
;(handler as any).client = mockClient

const tools: OpenAI.Chat.ChatCompletionTool[] = [
{
type: "function",
function: {
name: "read_file",
description: "Read files",
parameters: {
type: "object",
properties: {
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
indentation: {
type: ["object", "null"],
properties: {
anchorLine: { type: ["integer", "null"] },
maxLevels: { type: ["integer", "null"] },
},
additionalProperties: false,
},
},
},
},
},
},
},
},
]

const stream = handler.createMessage("system prompt", [], {
taskId: "test-task-id",
tools,
toolProtocol: "native" as const,
})

for await (const _ of stream) {
// consume
}

const tool = capturedRequestBody.tools[0]
expect(tool.strict).toBe(true)
const indentation = tool.parameters.properties.files.items.properties.indentation
expect(indentation.required).toEqual(["anchorLine", "maxLevels"])
})

it("should recursively add additionalProperties: false to nested objects in MCP tools", async () => {
let capturedRequestBody: any

Expand Down
40 changes: 35 additions & 5 deletions src/api/providers/base-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,17 @@ export abstract class BaseProvider implements ApiHandler {
* This matches the behavior of ensureAllRequired in openai-native.ts
*/
protected convertToolSchemaForOpenAI(schema: any): any {
if (!schema || typeof schema !== "object" || schema.type !== "object") {
const isObjectLikeSchema = (candidate: any): boolean => {
if (!candidate || typeof candidate !== "object") return false
const type = candidate.type
return (
type === "object" ||
(Array.isArray(type) && type.includes("object")) ||
candidate.properties !== undefined
)
}

if (!isObjectLikeSchema(schema)) {
return schema
}

Expand Down Expand Up @@ -89,19 +99,39 @@ export abstract class BaseProvider implements ApiHandler {
prop.type = nonNullTypes.length === 1 ? nonNullTypes[0] : nonNullTypes
}

// Recursively process nested objects
if (prop && prop.type === "object") {
// Recursively process nested objects (including nullable objects)
if (isObjectLikeSchema(prop)) {
newProps[key] = this.convertToolSchemaForOpenAI(prop)
} else if (prop && prop.type === "array" && prop.items?.type === "object") {
} else if (prop && prop.type === "array" && prop.items && isObjectLikeSchema(prop.items)) {
newProps[key] = {
...prop,
items: this.convertToolSchemaForOpenAI(prop.items),
items: Array.isArray(prop.items)
? prop.items.map((i: any) => this.convertToolSchemaForOpenAI(i))
: this.convertToolSchemaForOpenAI(prop.items),
}
}
}
result.properties = newProps
}

// Also recurse through unions if present
if (Array.isArray((result as any).anyOf)) {
;(result as any).anyOf = (result as any).anyOf.map((s: any) => this.convertToolSchemaForOpenAI(s))
}
if (Array.isArray((result as any).oneOf)) {
;(result as any).oneOf = (result as any).oneOf.map((s: any) => this.convertToolSchemaForOpenAI(s))
}
if (Array.isArray((result as any).allOf)) {
;(result as any).allOf = (result as any).allOf.map((s: any) => this.convertToolSchemaForOpenAI(s))
}

if ((result as any).type === "array" && (result as any).items) {
const items = (result as any).items
;(result as any).items = Array.isArray(items)
? items.map((i: any) => this.convertToolSchemaForOpenAI(i))
: this.convertToolSchemaForOpenAI(items)
}

return result
}

Expand Down
64 changes: 49 additions & 15 deletions src/api/providers/openai-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,31 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
reasoningEffort: ReasoningEffortExtended | undefined,
metadata?: ApiHandlerCreateMessageMetadata,
): any {
const isObjectLikeSchema = (schema: any): boolean => {
if (!schema || typeof schema !== "object") return false
const type = schema.type
return (
type === "object" || (Array.isArray(type) && type.includes("object")) || schema.properties !== undefined
)
}

const ensureAllRequired = (schema: any): any => {
if (!schema || typeof schema !== "object" || schema.type !== "object") {
// Only object-like schemas need "required" injection for OpenAI strict schemas.
if (!isObjectLikeSchema(schema)) {
// Still recurse into anyOf/oneOf/allOf arrays if present.
if (schema && typeof schema === "object") {
const out = { ...schema }
if (Array.isArray(out.anyOf)) out.anyOf = out.anyOf.map(ensureAllRequired)
if (Array.isArray(out.oneOf)) out.oneOf = out.oneOf.map(ensureAllRequired)
if (Array.isArray(out.allOf)) out.allOf = out.allOf.map(ensureAllRequired)
// Recurse into array item schemas too.
if (out.type === "array" && out.items) {
out.items = Array.isArray(out.items)
? out.items.map(ensureAllRequired)
: ensureAllRequired(out.items)
}
return out
}
return schema
}

Expand All @@ -224,47 +247,58 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
const newProps = { ...result.properties }
for (const key of allKeys) {
const prop = newProps[key]
if (prop.type === "object") {
if (prop && typeof prop === "object") {
// Recurse for nested object schemas, nullable object schemas (type: ["object","null"]),
// union schemas (anyOf), and arrays-of-objects.
newProps[key] = ensureAllRequired(prop)
} else if (prop.type === "array" && prop.items?.type === "object") {
newProps[key] = {
...prop,
items: ensureAllRequired(prop.items),
}
}
}
result.properties = newProps
}

if (Array.isArray(result.anyOf)) result.anyOf = result.anyOf.map(ensureAllRequired)
if (Array.isArray(result.oneOf)) result.oneOf = result.oneOf.map(ensureAllRequired)
if (Array.isArray(result.allOf)) result.allOf = result.allOf.map(ensureAllRequired)
if (result.type === "array" && result.items) {
result.items = Array.isArray(result.items)
? result.items.map(ensureAllRequired)
: ensureAllRequired(result.items)
}

return result
}

const ensureAdditionalPropertiesFalse = (schema: any): any => {
if (!schema || typeof schema !== "object" || schema.type !== "object") {
if (!schema || typeof schema !== "object") {
return schema
}

const result = { ...schema }
if (result.additionalProperties !== false) {
if (isObjectLikeSchema(result) && result.additionalProperties !== false) {
result.additionalProperties = false
}

if (result.properties) {
const newProps = { ...result.properties }
for (const key of Object.keys(result.properties)) {
const prop = newProps[key]
if (prop && prop.type === "object") {
if (prop && typeof prop === "object") {
newProps[key] = ensureAdditionalPropertiesFalse(prop)
} else if (prop && prop.type === "array" && prop.items?.type === "object") {
newProps[key] = {
...prop,
items: ensureAdditionalPropertiesFalse(prop.items),
}
}
}
result.properties = newProps
}

if (Array.isArray(result.anyOf)) result.anyOf = result.anyOf.map(ensureAdditionalPropertiesFalse)
if (Array.isArray(result.oneOf)) result.oneOf = result.oneOf.map(ensureAdditionalPropertiesFalse)
if (Array.isArray(result.allOf)) result.allOf = result.allOf.map(ensureAdditionalPropertiesFalse)

if (result.type === "array" && result.items) {
result.items = Array.isArray(result.items)
? result.items.map(ensureAdditionalPropertiesFalse)
: ensureAdditionalPropertiesFalse(result.items)
}

return result
}

Expand Down
Loading
Loading