Skip to content

feat: introduce registry for tool handlers and schemas #779

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
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
14 changes: 14 additions & 0 deletions codex-cli/src/tools/register-default-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { shellToolDefinition } from "./shell/definition.js";
import { handleShellTool } from "./shell/handler.js";
import { registerTool } from "./tool-registry.js";

/**
* Registers the default built-in tools like "shell".
*/
export function registerDefaultTools(): void {
registerTool({
definition: shellToolDefinition,
handler: handleShellTool,
aliases: ["container.exec", "container_exec"],
});
}
18 changes: 18 additions & 0 deletions codex-cli/src/tools/shell/definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { FunctionTool } from "openai/resources/responses/responses.mjs";

export const shellToolDefinition: FunctionTool = {
type: "function",
name: "shell",
description: "Runs a shell command, and returns its output.",
strict: false,
parameters: {
type: "object",
properties: {
command: { type: "array", items: { type: "string" } },
workdir: { type: "string" },
timeout: { type: "number" },
},
required: ["command"],
additionalProperties: false,
},
};
20 changes: 20 additions & 0 deletions codex-cli/src/tools/shell/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ExecInput } from "../../utils/agent/sandbox/interface.js";
import type { ToolHandler } from "../tool-registry.js";

import { handleExecCommand } from "../../utils/agent/handle-exec-command.js";

export const handleShellTool: ToolHandler = async (args, ctx) => {
const { outputText, metadata, additionalItems } = await handleExecCommand(
args as ExecInput,
ctx.config,
ctx.approvalPolicy,
ctx.additionalWritableRoots,
ctx.getCommandConfirmation,
ctx.signal,
);

return {
output: JSON.stringify({ output: outputText, metadata }),
additionalItems,
};
};
127 changes: 127 additions & 0 deletions codex-cli/src/tools/tool-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { ApplyPatchCommand, ApprovalPolicy } from "../approvals.js";
import type { CommandConfirmation } from "../utils/agent/agent-loop.js";
import type { AppConfig } from "../utils/config.js";
import type {
FunctionTool,
ResponseInputItem,
} from "openai/resources/responses/responses.mjs";

/**
* Shared tool registry for Codex CLI.
*
* Stores function-callable tools along with their execution logic.
* Each tool is defined by an OpenAI-compatible schema and a handler function.
*/

/**
* Context passed to each registered tool handler.
*/
export type ToolHandlerContext = {
config: AppConfig;
approvalPolicy: ApprovalPolicy;
cwd: string;
signal?: AbortSignal;
additionalWritableRoots: ReadonlyArray<string>;
getCommandConfirmation: (
command: Array<string>,
patch?: ApplyPatchCommand,
) => Promise<CommandConfirmation>;
};

/**
* Result returned by a tool handler.
*
* - `output`: The main result string to include in function_call_output.
* - `additionalItems`: Optional list of extra items to be streamed alongside (e.g., patches, messages).
*/
export type ToolHandlerResult = {
output: string;
additionalItems?: Array<ResponseInputItem>;
};

/**
* ToolHandler represents a function-callable unit of work.
*/
export type ToolHandler = (
args: Record<string, unknown>,
context: ToolHandlerContext,
) => Promise<ToolHandlerResult>;

/**
* A complete tool, combining schema definition and execution logic.
*/
export interface RegisteredTool {
definition: FunctionTool;
handler: ToolHandler;
aliases?: Array<string>;
}

// In-memory tool registry
const toolRegistry: Record<string, RegisteredTool> = {};

/**
* Maps alias tool names to their canonical tool names.
*
* Aliases are used to support legacy or semantic tool identifiers (e.g. "container.exec")
* This map is only used for internal lookup and is not passed to the model.
*/
const toolAliasMap: Record<string, string> = {};

/**
* Registers a tool definition and its associated handler.
*/
export function registerTool(tool: RegisteredTool): void {
const { definition, handler, aliases = [] } = tool;
const name = definition.name;

if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error(
`Tool name "${name}" is invalid. Must match /^[a-zA-Z0-9_-]+$/`,
);
}

toolRegistry[name] = { definition, handler, aliases };

for (const alias of aliases) {
toolAliasMap[alias] = name;
}
}

/**
* Retrieves the registered handler for a tool.
*/
export function getToolHandler(name: string): ToolHandler | undefined {
const resolvedName = toolAliasMap[name] || name;
return toolRegistry[resolvedName]?.handler;
}

/**
* Returns all registered tool schemas (to be passed to OpenAI).
*/
export function getRegisteredToolDefinitions(): Array<FunctionTool> {
return Object.values(toolRegistry).map((t) => t.definition);
}

/**
* Returns the names of all registered tools.
*/
export function getRegisteredToolNames(): Array<string> {
return Object.keys(toolRegistry);
}

/**
* Returns tool definitions along with their registered aliases.
*/
export function getToolSummaries(): Array<{
name: string;
description?: string;
aliases?: Array<string>;
}> {
return Object.entries(toolRegistry).map(
([name, { definition, aliases }]) => ({
name,
description: definition.description ?? undefined,
aliases,
}),
);
}
71 changes: 25 additions & 46 deletions codex-cli/src/utils/agent/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import type {
ResponseInputItem,
ResponseItem,
ResponseCreateParams,
FunctionTool,
} from "openai/resources/responses/responses.mjs";
import type { Reasoning } from "openai/resources.mjs";

import { registerDefaultTools } from "../../tools/register-default-tools.js";
import {
getToolHandler,
getRegisteredToolDefinitions,
} from "../../tools/tool-registry.js";
import {
OPENAI_TIMEOUT_MS,
OPENAI_ORGANIZATION,
Expand All @@ -28,7 +32,6 @@ import {
setCurrentModel,
setSessionId,
} from "../session.js";
import { handleExecCommand } from "./handle-exec-command.js";
import { HttpsProxyAgent } from "https-proxy-agent";
import { randomUUID } from "node:crypto";
import OpenAI, { APIConnectionTimeoutError } from "openai";
Expand Down Expand Up @@ -80,30 +83,6 @@ type AgentLoopParams = {
onLastResponseId: (lastResponseId: string) => void;
};

const shellTool: FunctionTool = {
type: "function",
name: "shell",
description: "Runs a shell command, and returns its output.",
strict: false,
parameters: {
type: "object",
properties: {
command: { type: "array", items: { type: "string" } },
workdir: {
type: "string",
description: "The working directory for the command.",
},
timeout: {
type: "number",
description:
"The maximum time to wait for the command to complete in milliseconds.",
},
},
required: ["command"],
additionalProperties: false,
},
};

export class AgentLoop {
private model: string;
private provider: string;
Expand Down Expand Up @@ -278,6 +257,8 @@ export class AgentLoop {
this.instructions = instructions;
this.approvalPolicy = approvalPolicy;

registerDefaultTools();

// If no `config` has been provided we derive a minimal stub so that the
// rest of the implementation can rely on `this.config` always being a
// defined object. We purposefully copy over the `model` and
Expand Down Expand Up @@ -414,24 +395,22 @@ export class AgentLoop {
// used to tell model to stop if needed
const additionalItems: Array<ResponseInputItem> = [];

// TODO: allow arbitrary function calls (beyond shell/container.exec)
if (name === "container.exec" || name === "shell") {
const {
outputText,
metadata,
additionalItems: additionalItemsFromExec,
} = await handleExecCommand(
args,
this.config,
this.approvalPolicy,
this.additionalWritableRoots,
this.getCommandConfirmation,
this.execAbortController?.signal,
);
outputItem.output = JSON.stringify({ output: outputText, metadata });

if (additionalItemsFromExec) {
additionalItems.push(...additionalItemsFromExec);
// Tool calls are handled dynamically via the tool handler registry
const handler = name ? getToolHandler(name) : undefined;
if (handler) {
const result = await handler(args, {
config: this.config,
approvalPolicy: this.approvalPolicy,
cwd: process.cwd(),
signal: this.execAbortController?.signal,
additionalWritableRoots: this.additionalWritableRoots,
getCommandConfirmation: this.getCommandConfirmation,
});

outputItem.output = result.output;

if (Array.isArray(result.additionalItems)) {
additionalItems.push(...result.additionalItems);
}
}

Expand Down Expand Up @@ -719,7 +698,7 @@ export class AgentLoop {
store: true,
previous_response_id: lastResponseId || undefined,
}),
tools: [shellTool],
tools: getRegisteredToolDefinitions(),
// Explicitly tell the model it is allowed to pick whatever
// tool it deems appropriate. Omitting this sometimes leads to
// the model ignoring the available tools and responding with
Expand Down Expand Up @@ -1095,7 +1074,7 @@ export class AgentLoop {
store: true,
previous_response_id: lastResponseId || undefined,
}),
tools: [shellTool],
tools: getRegisteredToolDefinitions(),
tool_choice: "auto",
});

Expand Down