diff --git a/codex-rs/core/src/tools/code_mode.rs b/codex-rs/core/src/tools/code_mode.rs index e8ca460ff..ba8dd29e0 100644 --- a/codex-rs/core/src/tools/code_mode.rs +++ b/codex-rs/core/src/tools/code_mode.rs @@ -10,6 +10,7 @@ use crate::exec_env::create_env; use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::tools::ToolRouter; +use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; use crate::tools::code_mode_description::code_mode_tool_reference; use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; @@ -51,8 +52,11 @@ enum CodeModeToolKind { #[derive(Clone, Debug, Serialize)] struct EnabledTool { tool_name: String, + #[serde(rename = "module")] + module_path: String, namespace: Vec, name: String, + description: String, kind: CodeModeToolKind, } @@ -107,7 +111,7 @@ pub(crate) fn instructions(config: &Config) -> Option { section.push_str(&format!( "- `{PUBLIC_TOOL_NAME}` uses the same Node runtime resolution as `js_repl`. If needed, point `js_repl_node_path` at the Node binary you want Codex to use.\n", )); - section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { tools } from \"tools.js\"`. Namespaced tools are also available from `tools/.js`; MCP tools use `tools/mcp/.js`, for example `import { append_notebook_logs_chart } from \"tools/mcp/ologs.js\"`. `tools[name]` and identifier wrappers like `await exec_command(args)` remain available for compatibility. Nested tool calls resolve to their code-mode result values.\n"); + section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { ALL_TOOLS } from \"tools.js\"` to inspect the available `{ module, name, description }` entries. Namespaced tools are also available from `tools/.js`; MCP tools use `tools/mcp/.js`, for example `import { append_notebook_logs_chart } from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values.\n"); section.push_str(&format!( "- Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, store, load }}` from `@openai/code_mode` (or `\"openai/code_mode\"`). `output_text(value)` surfaces text back to the model and stringifies non-string objects with `JSON.stringify(...)` when possible. `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs. `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, and `load(key)` returns a cloned stored value or `undefined`. `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate the final Rust-side result of the current `{PUBLIC_TOOL_NAME}` execution; the default is `10000`. This guards the overall `{PUBLIC_TOOL_NAME}` output, not individual nested tool invocations. The returned content starts with a separate `Script completed` or `Script failed` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker.\n", )); @@ -348,27 +352,43 @@ fn truncate_code_mode_result( async fn build_enabled_tools(exec: &ExecContext) -> Vec { let router = build_nested_router(exec).await; - let mut out = Vec::new(); - for spec in router.specs() { - let tool_name = spec.name().to_string(); - if tool_name == PUBLIC_TOOL_NAME { - continue; - } - - let reference = code_mode_tool_reference(&tool_name); - - out.push(EnabledTool { - tool_name, - namespace: reference.namespace, - name: reference.tool_key, - kind: tool_kind_for_spec(&spec), - }); - } + let mut out = router + .specs() + .into_iter() + .map(|spec| augment_tool_spec_for_code_mode(spec, true)) + .filter_map(enabled_tool_from_spec) + .collect::>(); out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); out.dedup_by(|left, right| left.tool_name == right.tool_name); out } +fn enabled_tool_from_spec(spec: ToolSpec) -> Option { + let tool_name = spec.name().to_string(); + if tool_name == PUBLIC_TOOL_NAME { + return None; + } + + let reference = code_mode_tool_reference(&tool_name); + + let (description, kind) = match spec { + ToolSpec::Function(tool) => (tool.description, CodeModeToolKind::Function), + ToolSpec::Freeform(tool) => (tool.description, CodeModeToolKind::Freeform), + ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } => { + return None; + } + }; + + Some(EnabledTool { + tool_name, + module_path: reference.module_path, + namespace: reference.namespace, + name: reference.tool_key, + description, + kind, + }) +} + async fn build_nested_router(exec: &ExecContext) -> ToolRouter { let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools(); let mcp_tools = exec diff --git a/codex-rs/core/src/tools/code_mode_bridge.js b/codex-rs/core/src/tools/code_mode_bridge.js index 362fc985b..435e94e74 100644 --- a/codex-rs/core/src/tools/code_mode_bridge.js +++ b/codex-rs/core/src/tools/code_mode_bridge.js @@ -55,13 +55,6 @@ globalThis.add_content = (value) => { return contentItems; }; -globalThis.tools = new Proxy(Object.create(null), { - get(_target, prop) { - const name = String(prop); - return async (args) => __codex_tool_call(name, args); - }, -}); - globalThis.console = Object.freeze({ log() {}, info() {}, @@ -71,7 +64,7 @@ globalThis.console = Object.freeze({ }); for (const name of __codexEnabledToolNames) { - if (/^[A-Za-z_$][0-9A-Za-z_$]*$/.test(name) && !(name in globalThis)) { + if (!(name in globalThis)) { Object.defineProperty(globalThis, name, { value: async (args) => __codex_tool_call(name, args), configurable: true, diff --git a/codex-rs/core/src/tools/code_mode_description.rs b/codex-rs/core/src/tools/code_mode_description.rs index b801ac035..2a3ba815c 100644 --- a/codex-rs/core/src/tools/code_mode_description.rs +++ b/codex-rs/core/src/tools/code_mode_description.rs @@ -75,11 +75,9 @@ fn append_code_mode_sample( output_type: String, ) -> String { let reference = code_mode_tool_reference(tool_name); - let local_name = code_mode_local_name(&reference.tool_key); - format!( - "{description}\n\nCode mode declaration:\n```ts\nimport {{ tools }} from \"{}\";\ndeclare function {local_name}({input_name}: {input_type}): Promise<{output_type}>;\n```", - reference.module_path + "{description}\n\nCode mode declaration:\n```ts\nimport {{ {} }} from \"{}\";\ndeclare function {}({input_name}: {input_type}): Promise<{output_type}>;\n```", + reference.tool_key, reference.module_path, reference.tool_key ) } @@ -100,22 +98,6 @@ fn code_mode_local_name(tool_key: &str) -> String { } } - if identifier.is_empty() { - return "tool_call".to_string(); - } - - if identifier == "tools" { - identifier.push_str("_tool"); - } - - if identifier - .chars() - .next() - .is_some_and(|ch| ch.is_ascii_digit()) - { - identifier.insert(0, '_'); - } - identifier } diff --git a/codex-rs/core/src/tools/code_mode_runner.cjs b/codex-rs/core/src/tools/code_mode_runner.cjs index 8e5cc9d38..f36fa6f92 100644 --- a/codex-rs/core/src/tools/code_mode_runner.cjs +++ b/codex-rs/core/src/tools/code_mode_runner.cjs @@ -108,10 +108,6 @@ function formatErrorText(error) { return String(error && error.stack ? error.stack : error); } -function isValidIdentifier(name) { - return /^[A-Za-z_$][0-9A-Za-z_$]*$/.test(name); -} - function cloneJsonValue(value) { return JSON.parse(JSON.stringify(value)); } @@ -139,12 +135,25 @@ function createToolsNamespace(callTool, enabledTools) { return Object.freeze(tools); } +function createAllToolsMetadata(enabledTools) { + return Object.freeze( + enabledTools.map(({ module: modulePath, name, description }) => + Object.freeze({ + module: modulePath, + name, + description, + }) + ) + ); +} + function createToolsModule(context, callTool, enabledTools) { const tools = createToolsNamespace(callTool, enabledTools); - const exportNames = ['tools']; + const allTools = createAllToolsMetadata(enabledTools); + const exportNames = ['ALL_TOOLS']; for (const { tool_name } of enabledTools) { - if (tool_name !== 'tools' && isValidIdentifier(tool_name)) { + if (tool_name !== 'ALL_TOOLS') { exportNames.push(tool_name); } } @@ -154,9 +163,9 @@ function createToolsModule(context, callTool, enabledTools) { return new SyntheticModule( uniqueExportNames, function initToolsModule() { - this.setExport('tools', tools); + this.setExport('ALL_TOOLS', allTools); for (const exportName of uniqueExportNames) { - if (exportName !== 'tools') { + if (exportName !== 'ALL_TOOLS') { this.setExport(exportName, tools[exportName]); } } @@ -283,10 +292,10 @@ function createNamespacedToolsNamespace(callTool, enabledTools, namespace) { function createNamespacedToolsModule(context, callTool, enabledTools, namespace) { const tools = createNamespacedToolsNamespace(callTool, enabledTools, namespace); - const exportNames = ['tools']; + const exportNames = []; for (const exportName of Object.keys(tools)) { - if (exportName !== 'tools' && isValidIdentifier(exportName)) { + if (exportName !== 'ALL_TOOLS') { exportNames.push(exportName); } } @@ -296,11 +305,8 @@ function createNamespacedToolsModule(context, callTool, enabledTools, namespace) return new SyntheticModule( uniqueExportNames, function initNamespacedToolsModule() { - this.setExport('tools', tools); for (const exportName of uniqueExportNames) { - if (exportName !== 'tools') { - this.setExport(exportName, tools[exportName]); - } + this.setExport(exportName, tools[exportName]); } }, { context } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 8aab13979..1b287a216 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1622,7 +1622,7 @@ source: /[\s\S]+/ enabled_tool_names.join(", ") }; let description = format!( - "Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `{PUBLIC_TOOL_NAME}` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ tools }} from \"tools.js\"`. Namespaced tools are also available from `tools/.js`; MCP tools use `tools/mcp/.js`, for example `import {{ append_notebook_logs_chart }} from \"tools/mcp/ologs.js\"`. `tools[name]` and identifier wrappers like `await shell(args)` remain available for compatibility when the tool name is a valid JS identifier. Nested tool calls resolve to their code-mode result values. Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, store, load }}` from `\"@openai/code_mode\"` (or `\"openai/code_mode\"`); `output_text(value)` surfaces text back to the model and stringifies non-string objects when possible, `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs, `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, `load(key)` returns a cloned stored value or `undefined`, and `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate the final Rust-side result of the current `{PUBLIC_TOOL_NAME}` execution. The default is `10000`. This guards the overall `{PUBLIC_TOOL_NAME}` output, not individual nested tool invocations. The returned content starts with a separate `Script completed` or `Script failed` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker. Function tools require JSON object arguments. Freeform tools require raw strings. `add_content(value)` remains available for compatibility with a content item, content-item array, or string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`. Only content passed to `output_text(...)`, `output_image(...)`, or `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}." + "Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `{PUBLIC_TOOL_NAME}` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ ALL_TOOLS }} from \"tools.js\"` to inspect the available `{{ module, name, description }}` entries. Namespaced tools are also available from `tools/.js`; MCP tools use `tools/mcp/.js`, for example `import {{ append_notebook_logs_chart }} from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values. Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, store, load }}` from `\"@openai/code_mode\"` (or `\"openai/code_mode\"`); `output_text(value)` surfaces text back to the model and stringifies non-string objects when possible, `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs, `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, `load(key)` returns a cloned stored value or `undefined`, and `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate the final Rust-side result of the current `{PUBLIC_TOOL_NAME}` execution. The default is `10000`. This guards the overall `{PUBLIC_TOOL_NAME}` output, not individual nested tool invocations. The returned content starts with a separate `Script completed` or `Script failed` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker. Function tools require JSON object arguments. Freeform tools require raw strings. `add_content(value)` remains available for compatibility with a content item, content-item array, or string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`. Only content passed to `output_text(...)`, `output_image(...)`, or `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}." ); ToolSpec::Freeform(FreeformTool { @@ -1636,6 +1636,10 @@ source: /[\s\S]+/ }) } +fn is_code_mode_nested_tool(spec: &ToolSpec) -> bool { + spec.name() != PUBLIC_TOOL_NAME && matches!(spec, ToolSpec::Function(_) | ToolSpec::Freeform(_)) +} + fn create_list_mcp_resources_tool() -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2041,8 +2045,9 @@ pub(crate) fn build_specs( .build(); let mut enabled_tool_names = nested_specs .into_iter() - .map(|spec| spec.spec.name().to_string()) - .filter(|name| name != PUBLIC_TOOL_NAME) + .map(|spec| spec.spec) + .filter(is_code_mode_nested_tool) + .map(|spec| spec.name().to_string()) .collect::>(); enabled_tool_names.sort(); enabled_tool_names.dedup(); @@ -4379,7 +4384,7 @@ Examples of valid command strings: assert_eq!( description, - "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nCode mode declaration:\n```ts\nimport { tools } from \"tools.js\";\ndeclare function view_image(args: {\n path: string;\n}): Promise;\n```" + "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nCode mode declaration:\n```ts\nimport { view_image } from \"tools.js\";\ndeclare function view_image(args: {\n path: string;\n}): Promise;\n```" ); } @@ -4428,7 +4433,7 @@ Examples of valid command strings: assert_eq!( description, - "Echo text\n\nCode mode declaration:\n```ts\nimport { tools } from \"tools/mcp/sample.js\";\ndeclare function echo(args: {\n message: string;\n}): Promise<{\n _meta?: unknown;\n content: Array;\n isError?: boolean;\n structuredContent?: unknown;\n}>;\n```" + "Echo text\n\nCode mode declaration:\n```ts\nimport { echo } from \"tools/mcp/sample.js\";\ndeclare function echo(args: {\n message: string;\n}): Promise<{\n _meta?: unknown;\n content: Array;\n isError?: boolean;\n structuredContent?: unknown;\n}>;\n```" ); } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index ecca32a33..07cadc343 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -495,38 +495,74 @@ contentLength=0" } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_can_access_namespaced_mcp_tool_from_flat_tools_namespace() -> Result<()> { +async fn code_mode_exports_all_tools_metadata_for_builtin_tools() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; let code = r#" -import { tools } from "tools.js"; +import { ALL_TOOLS } from "tools.js"; -const { structuredContent, isError } = await tools["mcp__rmcp__echo"]({ - message: "ping", -}); -add_content( - `echo=${structuredContent?.echo ?? "missing"}\n` + - `env=${structuredContent?.env ?? "missing"}\n` + - `isError=${String(isError)}` -); +const tool = ALL_TOOLS.find(({ module, name }) => module === "tools.js" && name === "view_image"); +add_content(JSON.stringify(tool)); "#; let (_test, second_mock) = - run_code_mode_turn_with_rmcp(&server, "use exec to run the rmcp echo tool", code).await?; + run_code_mode_turn(&server, "use exec to inspect ALL_TOOLS", code, false).await?; let req = second_mock.single_request(); let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); assert_ne!( success, Some(false), - "exec rmcp echo call failed unexpectedly: {output}" + "exec ALL_TOOLS lookup failed unexpectedly: {output}" ); + + let parsed: Value = serde_json::from_str(&output)?; assert_eq!( - output, - "echo=ECHOING: ping -env=propagated-env -isError=false" + parsed, + serde_json::json!({ + "module": "tools.js", + "name": "view_image", + "description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nCode mode declaration:\n```ts\nimport { view_image } from \"tools.js\";\ndeclare function view_image(args: {\n path: string;\n}): Promise;\n```", + }) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exports_all_tools_metadata_for_namespaced_mcp_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let code = r#" +import { ALL_TOOLS } from "tools.js"; + +const tool = ALL_TOOLS.find( + ({ module, name }) => module === "tools/mcp/rmcp.js" && name === "echo" +); +add_content(JSON.stringify(tool)); +"#; + + let (_test, second_mock) = + run_code_mode_turn_with_rmcp(&server, "use exec to inspect ALL_TOOLS", code).await?; + + let req = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "exec ALL_TOOLS MCP lookup failed unexpectedly: {output}" + ); + + let parsed: Value = serde_json::from_str(&output)?; + assert_eq!( + parsed, + serde_json::json!({ + "module": "tools/mcp/rmcp.js", + "name": "echo", + "description": "Echo back the provided message and include environment data.\n\nCode mode declaration:\n```ts\nimport { echo } from \"tools/mcp/rmcp.js\";\ndeclare function echo(args: {\n env_var?: string;\n message: string;\n}): Promise<{\n _meta?: unknown;\n content: Array;\n isError?: boolean;\n structuredContent?: unknown;\n}>;\n```", + }) ); Ok(())