mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
Merge branch 'main' into etraut/tuiappserver-mcp-regression
This commit is contained in:
@@ -77,6 +77,11 @@ build:clippy --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect
|
||||
build:clippy --output_groups=+clippy_checks
|
||||
build:clippy --@rules_rust//rust/settings:clippy.toml=//codex-rs:clippy.toml
|
||||
|
||||
# Shared config for Bazel-backed argument-comment-lint.
|
||||
build:argument-comment-lint --aspects=//tools/argument-comment-lint:lint_aspect.bzl%rust_argument_comment_lint_aspect
|
||||
build:argument-comment-lint --output_groups=argument_comment_lint_checks
|
||||
build:argument-comment-lint --@rules_rust//rust/toolchain/channel=nightly
|
||||
|
||||
# Rearrange caches on Windows so they're on the same volume as the checkout.
|
||||
common:ci-windows --config=ci-bazel
|
||||
common:ci-windows --build_metadata=TAG_os=windows
|
||||
|
||||
@@ -57,9 +57,11 @@ runs:
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Use a very short path to reduce argv/path length issues, but avoid the
|
||||
# drive root because some Windows test launchers mis-handle MANIFEST paths there.
|
||||
"BAZEL_OUTPUT_USER_ROOT=D:\b" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
# Use the shortest available drive to reduce argv/path length issues,
|
||||
# but avoid the drive root because some Windows test launchers mis-handle
|
||||
# MANIFEST paths there.
|
||||
$bazelOutputUserRoot = if (Test-Path 'D:\') { 'D:\b' } else { 'C:\b' }
|
||||
"BAZEL_OUTPUT_USER_ROOT=$bazelOutputUserRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Enable Git long paths (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
--use-node-test-env \
|
||||
-- \
|
||||
test \
|
||||
--test_tag_filters=-argument-comment-lint \
|
||||
--test_verbose_timeout_warnings \
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
|
||||
-- \
|
||||
@@ -98,6 +99,7 @@ jobs:
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
|
||||
clippy:
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -73,6 +73,7 @@ jobs:
|
||||
argument_comment_lint_prebuilt:
|
||||
name: Argument comment lint - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runs_on || matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -88,29 +89,45 @@ jobs:
|
||||
labels: codex-windows-x64
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: ./.github/actions/setup-bazel-ci
|
||||
with:
|
||||
target: ${{ runner.os }}
|
||||
install-test-prereqs: true
|
||||
- name: Install Linux sandbox build dependencies
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
with:
|
||||
toolchain: nightly-2025-09-18
|
||||
components: llvm-tools-preview, rustc-dev, rust-src
|
||||
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
|
||||
- name: Run argument comment lint on codex-rs
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
shell: bash
|
||||
run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py
|
||||
- name: Run argument comment lint on codex-rs (default targets only)
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
shell: bash
|
||||
run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py -- --lib --bins
|
||||
- name: Run argument comment lint on codex-rs
|
||||
- name: Install nightly argument-comment-lint toolchain
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
shell: bash
|
||||
run: python ./tools/argument-comment-lint/run-prebuilt-linter.py
|
||||
run: |
|
||||
rustup toolchain install nightly-2025-09-18 \
|
||||
--profile minimal \
|
||||
--component llvm-tools-preview \
|
||||
--component rustc-dev \
|
||||
--component rust-src \
|
||||
--no-self-update
|
||||
rustup default nightly-2025-09-18
|
||||
- name: Run argument comment lint on codex-rs via Bazel
|
||||
if: ${{ runner.os != 'Windows' }}
|
||||
env:
|
||||
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
./.github/scripts/run-bazel-ci.sh \
|
||||
-- \
|
||||
build \
|
||||
--config=argument-comment-lint \
|
||||
--keep_going \
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
|
||||
-- \
|
||||
//codex-rs/...
|
||||
- name: Run argument comment lint on codex-rs via packaged wrapper
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
shell: bash
|
||||
run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py
|
||||
|
||||
# --- CI to validate on different os/targets --------------------------------
|
||||
lint_build:
|
||||
|
||||
@@ -93,9 +93,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
with:
|
||||
toolchain: nightly-2025-09-18
|
||||
components: llvm-tools-preview, rustc-dev, rust-src
|
||||
- name: Install nightly argument-comment-lint toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
rustup toolchain install nightly-2025-09-18 \
|
||||
--profile minimal \
|
||||
--component llvm-tools-preview \
|
||||
--component rustc-dev \
|
||||
--component rust-src \
|
||||
--no-self-update
|
||||
rustup default nightly-2025-09-18
|
||||
- name: Cache cargo-dylint tooling
|
||||
id: cargo_dylint_cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
@@ -121,6 +128,7 @@ jobs:
|
||||
argument_comment_lint_prebuilt:
|
||||
name: Argument comment lint - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runs_on || matrix.runner }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
needs: changed
|
||||
if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' }}
|
||||
strategy:
|
||||
@@ -129,40 +137,57 @@ jobs:
|
||||
include:
|
||||
- name: Linux
|
||||
runner: ubuntu-24.04
|
||||
timeout_minutes: 30
|
||||
- name: macOS
|
||||
runner: macos-15-xlarge
|
||||
timeout_minutes: 30
|
||||
- name: Windows
|
||||
runner: windows-x64
|
||||
timeout_minutes: 30
|
||||
runs_on:
|
||||
group: codex-runners
|
||||
labels: codex-windows-x64
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: ./.github/actions/setup-bazel-ci
|
||||
with:
|
||||
target: ${{ runner.os }}
|
||||
install-test-prereqs: true
|
||||
- name: Install Linux sandbox build dependencies
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
with:
|
||||
toolchain: nightly-2025-09-18
|
||||
components: llvm-tools-preview, rustc-dev, rust-src
|
||||
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
|
||||
- name: Run argument comment lint on codex-rs
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
shell: bash
|
||||
run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py
|
||||
# Linux still uses the default-targets-only form for now, but PRs run the
|
||||
# released linter on all three platforms so wrapper regressions surface pre-merge.
|
||||
- name: Run argument comment lint on codex-rs (default targets only)
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
shell: bash
|
||||
run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py -- --lib --bins
|
||||
- name: Run argument comment lint on codex-rs
|
||||
- name: Install nightly argument-comment-lint toolchain
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
shell: bash
|
||||
run: python ./tools/argument-comment-lint/run-prebuilt-linter.py
|
||||
run: |
|
||||
rustup toolchain install nightly-2025-09-18 \
|
||||
--profile minimal \
|
||||
--component llvm-tools-preview \
|
||||
--component rustc-dev \
|
||||
--component rust-src \
|
||||
--no-self-update
|
||||
rustup default nightly-2025-09-18
|
||||
- name: Run argument comment lint on codex-rs via Bazel
|
||||
if: ${{ runner.os != 'Windows' }}
|
||||
env:
|
||||
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
./.github/scripts/run-bazel-ci.sh \
|
||||
-- \
|
||||
build \
|
||||
--config=argument-comment-lint \
|
||||
--keep_going \
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
|
||||
-- \
|
||||
//codex-rs/...
|
||||
- name: Run argument comment lint on codex-rs via packaged wrapper
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
shell: bash
|
||||
run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py
|
||||
|
||||
# --- Gatherer job that you mark as the ONLY required status -----------------
|
||||
results:
|
||||
|
||||
+40
-3
@@ -27,11 +27,11 @@ register_toolchains("@llvm//toolchain:all")
|
||||
|
||||
osx = use_extension("@llvm//extensions:osx.bzl", "osx")
|
||||
osx.from_archive(
|
||||
sha256 = "6a4922f89487a96d7054ec6ca5065bfddd9f1d017c74d82f1d79cecf7feb8228",
|
||||
strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.2.sdk",
|
||||
sha256 = "1bde70c0b1c2ab89ff454acbebf6741390d7b7eb149ca2a3ca24cc9203a408b7",
|
||||
strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.4.sdk",
|
||||
type = "pkg",
|
||||
urls = [
|
||||
"https://swcdn.apple.com/content/downloads/26/44/047-81934-A_28TPKM5SD1/ps6pk6dk4x02vgfa5qsctq6tgf23t5f0w2/CLTools_macOSNMOS_SDK.pkg",
|
||||
"https://swcdn.apple.com/content/downloads/32/53/047-96692-A_OAHIHT53YB/ybtshxmrcju8m2qvw3w5elr4rajtg1x3y3/CLTools_macOSNMOS_SDK.pkg",
|
||||
],
|
||||
)
|
||||
osx.frameworks(names = [
|
||||
@@ -87,6 +87,17 @@ rules_rust.patch(
|
||||
)
|
||||
use_repo(rules_rust, "rules_rust")
|
||||
|
||||
nightly_rust = use_extension(
|
||||
"@rules_rs//rs/experimental:rules_rust_reexported_extensions.bzl",
|
||||
"rust",
|
||||
)
|
||||
nightly_rust.toolchain(
|
||||
versions = ["nightly/2025-09-18"],
|
||||
dev_components = True,
|
||||
edition = "2024",
|
||||
)
|
||||
use_repo(nightly_rust, "rust_toolchains")
|
||||
|
||||
toolchains = use_extension("@rules_rs//rs/experimental/toolchains:module_extension.bzl", "toolchains")
|
||||
toolchains.toolchain(
|
||||
edition = "2024",
|
||||
@@ -95,6 +106,7 @@ toolchains.toolchain(
|
||||
use_repo(toolchains, "default_rust_toolchains")
|
||||
|
||||
register_toolchains("@default_rust_toolchains//:all")
|
||||
register_toolchains("@rust_toolchains//:all")
|
||||
|
||||
crate = use_extension("@rules_rs//rs:extensions.bzl", "crate")
|
||||
crate.from_cargo(
|
||||
@@ -117,6 +129,24 @@ crate.from_cargo(
|
||||
],
|
||||
use_experimental_platforms = True,
|
||||
)
|
||||
crate.from_cargo(
|
||||
name = "argument_comment_lint_crates",
|
||||
cargo_lock = "//tools/argument-comment-lint:Cargo.lock",
|
||||
cargo_toml = "//tools/argument-comment-lint:Cargo.toml",
|
||||
platform_triples = [
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-pc-windows-msvc",
|
||||
"aarch64-pc-windows-gnullvm",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-unknown-linux-musl",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"x86_64-pc-windows-gnullvm",
|
||||
],
|
||||
use_experimental_platforms = True,
|
||||
)
|
||||
|
||||
bazel_dep(name = "zstd", version = "1.5.7")
|
||||
|
||||
@@ -136,7 +166,14 @@ crate.annotation(
|
||||
],
|
||||
)
|
||||
|
||||
crate.annotation(
|
||||
# The build script only validates embedded source/version metadata.
|
||||
crate = "rustc_apfloat",
|
||||
gen_build_script = "off",
|
||||
)
|
||||
|
||||
inject_repo(crate, "zstd")
|
||||
use_repo(crate, "argument_comment_lint_crates")
|
||||
|
||||
bazel_dep(name = "bzip2", version = "1.0.8.bcr.3")
|
||||
|
||||
|
||||
Generated
+64
File diff suppressed because one or more lines are too long
@@ -2,3 +2,17 @@ exports_files([
|
||||
"clippy.toml",
|
||||
"node-version.txt",
|
||||
])
|
||||
|
||||
filegroup(
|
||||
name = "workspace-files",
|
||||
srcs = glob(
|
||||
[
|
||||
"*",
|
||||
".cargo/**",
|
||||
],
|
||||
exclude = [
|
||||
"BUILD.bazel",
|
||||
],
|
||||
),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
Generated
+1
@@ -2613,6 +2613,7 @@ dependencies = [
|
||||
name = "codex-tools"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-code-mode",
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"rmcp",
|
||||
|
||||
@@ -61,7 +61,6 @@ use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
use codex_core::default_client::DEFAULT_ORIGINATOR;
|
||||
use codex_core::default_client::SetOriginatorError;
|
||||
use codex_core::default_client::USER_AGENT_SUFFIX;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
@@ -88,7 +87,6 @@ use toml::Value as TomlValue;
|
||||
use tracing::Instrument;
|
||||
|
||||
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TUI_CLIENT_NAME: &str = "codex-tui";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExternalAuthRefreshBridge {
|
||||
@@ -569,13 +567,7 @@ impl MessageProcessor {
|
||||
} = params.client_info;
|
||||
session.app_server_client_name = Some(name.clone());
|
||||
session.client_version = Some(version.clone());
|
||||
let originator = if name == TUI_CLIENT_NAME {
|
||||
// TODO: Remove this temporary workaround once the TUI no longer
|
||||
// needs to retain the `codex_cli_rs` originator behavior.
|
||||
DEFAULT_ORIGINATOR.to_string()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
let originator = name.clone();
|
||||
if let Err(error) = set_default_originator(originator) {
|
||||
match error {
|
||||
SetOriginatorError::InvalidHeaderValue => {
|
||||
|
||||
@@ -72,6 +72,7 @@ use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_tools::create_tools_json_for_responses_api;
|
||||
use eventsource_stream::Event;
|
||||
use eventsource_stream::EventStreamError;
|
||||
use futures::StreamExt;
|
||||
@@ -107,7 +108,6 @@ use crate::response_debug_context::extract_response_debug_context;
|
||||
use crate::response_debug_context::extract_response_debug_context_from_api_error;
|
||||
use crate::response_debug_context::telemetry_api_error_message;
|
||||
use crate::response_debug_context::telemetry_transport_error_message;
|
||||
use crate::tools::spec::create_tools_json_for_responses_api;
|
||||
use crate::util::FeedbackRequestTags;
|
||||
use crate::util::emit_feedback_auth_recovery_tags;
|
||||
use crate::util::emit_feedback_request_tags_with_auth_env;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::config::types::Personality;
|
||||
use crate::error::Result;
|
||||
pub use codex_api::common::ResponseEvent;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_tools::ToolSpec;
|
||||
use futures::Stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
@@ -157,107 +157,11 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
|
||||
}
|
||||
|
||||
pub(crate) mod tools {
|
||||
use codex_protocol::config_types::WebSearchContextSize;
|
||||
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
|
||||
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
|
||||
use codex_protocol::config_types::WebSearchUserLocationType;
|
||||
pub(crate) use codex_tools::FreeformTool;
|
||||
pub(crate) use codex_tools::FreeformToolFormat;
|
||||
use codex_tools::JsonSchema;
|
||||
pub(crate) use codex_tools::ResponsesApiTool;
|
||||
pub(crate) use codex_tools::ToolSearchOutputTool;
|
||||
use serde::Serialize;
|
||||
|
||||
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
|
||||
/// Responses API.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum ToolSpec {
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
#[serde(rename = "tool_search")]
|
||||
ToolSearch {
|
||||
execution: String,
|
||||
description: String,
|
||||
parameters: JsonSchema,
|
||||
},
|
||||
#[serde(rename = "local_shell")]
|
||||
LocalShell {},
|
||||
#[serde(rename = "image_generation")]
|
||||
ImageGeneration { output_format: String },
|
||||
// TODO: Understand why we get an error on web_search although the API docs say it's supported.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
|
||||
// The `external_web_access` field determines whether the web search is over cached or live content.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search#live-internet-access
|
||||
#[serde(rename = "web_search")]
|
||||
WebSearch {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_web_access: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
filters: Option<ResponsesApiWebSearchFilters>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_location: Option<ResponsesApiWebSearchUserLocation>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_context_size: Option<WebSearchContextSize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_content_types: Option<Vec<String>>,
|
||||
},
|
||||
#[serde(rename = "custom")]
|
||||
Freeform(FreeformTool),
|
||||
}
|
||||
|
||||
impl ToolSpec {
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
match self {
|
||||
ToolSpec::Function(tool) => tool.name.as_str(),
|
||||
ToolSpec::ToolSearch { .. } => "tool_search",
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration { .. } => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
ToolSpec::Freeform(tool) => tool.name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub(crate) struct ResponsesApiWebSearchFilters {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) allowed_domains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<ConfigWebSearchFilters> for ResponsesApiWebSearchFilters {
|
||||
fn from(filters: ConfigWebSearchFilters) -> Self {
|
||||
Self {
|
||||
allowed_domains: filters.allowed_domains,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub(crate) struct ResponsesApiWebSearchUserLocation {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) r#type: WebSearchUserLocationType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) country: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) city: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigWebSearchUserLocation> for ResponsesApiWebSearchUserLocation {
|
||||
fn from(user_location: ConfigWebSearchUserLocation) -> Self {
|
||||
Self {
|
||||
r#type: user_location.r#type,
|
||||
country: user_location.country,
|
||||
region: user_location.region,
|
||||
city: user_location.city,
|
||||
timezone: user_location.timezone,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) use codex_tools::ToolSpec;
|
||||
}
|
||||
|
||||
pub struct ResponseStream {
|
||||
|
||||
@@ -4385,10 +4385,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
handlers::reload_user_config(&sess).await;
|
||||
false
|
||||
}
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
false
|
||||
}
|
||||
Op::ListSkills { cwds, force_reload } => {
|
||||
handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
|
||||
false
|
||||
@@ -4514,13 +4510,11 @@ mod handlers {
|
||||
use crate::tasks::UserShellCommandMode;
|
||||
use crate::tasks::UserShellCommandTask;
|
||||
use crate::tasks::execute_user_shell_command;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_protocol::protocol::ListSkillsResponseEvent;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -4907,23 +4901,6 @@ mod handlers {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
pub async fn list_custom_prompts(sess: &Session, sub_id: String) {
|
||||
let custom_prompts: Vec<CustomPrompt> =
|
||||
if let Some(dir) = crate::custom_prompts::default_prompts_dir() {
|
||||
crate::custom_prompts::discover_prompts_in(&dir).await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent {
|
||||
custom_prompts,
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
pub async fn list_skills(
|
||||
sess: &Session,
|
||||
sub_id: String,
|
||||
@@ -6845,7 +6822,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
|
||||
| EventMsg::TurnDiff(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::SkillsUpdateAvailable
|
||||
| EventMsg::PlanUpdate(_)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
/// Return the default prompts directory: `$CODEX_HOME/prompts`.
|
||||
/// If `CODEX_HOME` cannot be resolved, returns `None`.
|
||||
pub fn default_prompts_dir() -> Option<PathBuf> {
|
||||
crate::config::find_codex_home()
|
||||
.ok()
|
||||
.map(|home| home.join("prompts"))
|
||||
}
|
||||
|
||||
/// Discover prompt files in the given directory, returning entries sorted by name.
|
||||
/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty.
|
||||
pub async fn discover_prompts_in(dir: &Path) -> Vec<CustomPrompt> {
|
||||
discover_prompts_in_excluding(dir, &HashSet::new()).await
|
||||
}
|
||||
|
||||
/// Discover prompt files in the given directory, excluding any with names in `exclude`.
|
||||
/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty.
|
||||
pub async fn discover_prompts_in_excluding(
|
||||
dir: &Path,
|
||||
exclude: &HashSet<String>,
|
||||
) -> Vec<CustomPrompt> {
|
||||
let mut out: Vec<CustomPrompt> = Vec::new();
|
||||
let mut entries = match fs::read_dir(dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return out,
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let is_file_like = fs::metadata(&path)
|
||||
.await
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false);
|
||||
if !is_file_like {
|
||||
continue;
|
||||
}
|
||||
// Only include Markdown files with a .md extension.
|
||||
let is_md = path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("md"))
|
||||
.unwrap_or(false);
|
||||
if !is_md {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if exclude.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let content = match fs::read_to_string(&path).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let (description, argument_hint, body) = parse_frontmatter(&content);
|
||||
out.push(CustomPrompt {
|
||||
name,
|
||||
path,
|
||||
content: body,
|
||||
description,
|
||||
argument_hint,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse optional YAML-like frontmatter at the beginning of `content`.
|
||||
/// Supported keys:
|
||||
/// - `description`: short description shown in the slash popup
|
||||
/// - `argument-hint` or `argument_hint`: brief hint string shown after the description
|
||||
/// Returns (description, argument_hint, body_without_frontmatter).
|
||||
fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, String) {
|
||||
let mut segments = content.split_inclusive('\n');
|
||||
let Some(first_segment) = segments.next() else {
|
||||
return (None, None, String::new());
|
||||
};
|
||||
let first_line = first_segment.trim_end_matches(['\r', '\n']);
|
||||
if first_line.trim() != "---" {
|
||||
return (None, None, content.to_string());
|
||||
}
|
||||
|
||||
let mut desc: Option<String> = None;
|
||||
let mut hint: Option<String> = None;
|
||||
let mut frontmatter_closed = false;
|
||||
let mut consumed = first_segment.len();
|
||||
|
||||
for segment in segments {
|
||||
let line = segment.trim_end_matches(['\r', '\n']);
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed == "---" {
|
||||
frontmatter_closed = true;
|
||||
consumed += segment.len();
|
||||
break;
|
||||
}
|
||||
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
consumed += segment.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((k, v)) = trimmed.split_once(':') {
|
||||
let key = k.trim().to_ascii_lowercase();
|
||||
let mut val = v.trim().to_string();
|
||||
if val.len() >= 2 {
|
||||
let bytes = val.as_bytes();
|
||||
let first = bytes[0];
|
||||
let last = bytes[bytes.len() - 1];
|
||||
if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') {
|
||||
val = val[1..val.len().saturating_sub(1)].to_string();
|
||||
}
|
||||
}
|
||||
match key.as_str() {
|
||||
"description" => desc = Some(val),
|
||||
"argument-hint" | "argument_hint" => hint = Some(val),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
consumed += segment.len();
|
||||
}
|
||||
|
||||
if !frontmatter_closed {
|
||||
// Unterminated frontmatter: treat input as-is.
|
||||
return (None, None, content.to_string());
|
||||
}
|
||||
|
||||
let body = if consumed >= content.len() {
|
||||
String::new()
|
||||
} else {
|
||||
content[consumed..].to_string()
|
||||
};
|
||||
(desc, hint, body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "custom_prompts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,95 +0,0 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_when_dir_missing() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let missing = tmp.path().join("nope");
|
||||
let found = discover_prompts_in(&missing).await;
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovers_and_sorts_files() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let dir = tmp.path();
|
||||
fs::write(dir.join("b.md"), b"b").unwrap();
|
||||
fs::write(dir.join("a.md"), b"a").unwrap();
|
||||
fs::create_dir(dir.join("subdir")).unwrap();
|
||||
let found = discover_prompts_in(dir).await;
|
||||
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
||||
assert_eq!(names, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn excludes_builtins() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let dir = tmp.path();
|
||||
fs::write(dir.join("init.md"), b"ignored").unwrap();
|
||||
fs::write(dir.join("foo.md"), b"ok").unwrap();
|
||||
let mut exclude = HashSet::new();
|
||||
exclude.insert("init".to_string());
|
||||
let found = discover_prompts_in_excluding(dir, &exclude).await;
|
||||
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
||||
assert_eq!(names, vec!["foo"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_non_utf8_files() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let dir = tmp.path();
|
||||
// Valid UTF-8 file
|
||||
fs::write(dir.join("good.md"), b"hello").unwrap();
|
||||
// Invalid UTF-8 content in .md file (e.g., lone 0xFF byte)
|
||||
fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap();
|
||||
let found = discover_prompts_in(dir).await;
|
||||
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
||||
assert_eq!(names, vec!["good"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn discovers_symlinked_md_files() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let dir = tmp.path();
|
||||
|
||||
// Create a real file
|
||||
fs::write(dir.join("real.md"), b"real content").unwrap();
|
||||
|
||||
// Create a symlink to the real file
|
||||
std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap();
|
||||
|
||||
let found = discover_prompts_in(dir).await;
|
||||
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
|
||||
|
||||
// Both real and link should be discovered, sorted alphabetically
|
||||
assert_eq!(names, vec!["link", "real"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_frontmatter_and_strips_from_body() {
|
||||
let tmp = tempdir().expect("create TempDir");
|
||||
let dir = tmp.path();
|
||||
let file = dir.join("withmeta.md");
|
||||
let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS";
|
||||
fs::write(&file, text).unwrap();
|
||||
|
||||
let found = discover_prompts_in(dir).await;
|
||||
assert_eq!(found.len(), 1);
|
||||
let p = &found[0];
|
||||
assert_eq!(p.name, "withmeta");
|
||||
assert_eq!(p.description.as_deref(), Some("Quick review command"));
|
||||
assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]"));
|
||||
// Body should not include the frontmatter delimiters.
|
||||
assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_preserves_body_newlines() {
|
||||
let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n";
|
||||
let (desc, hint, body) = parse_frontmatter(content);
|
||||
assert_eq!(desc.as_deref(), Some("Line endings"));
|
||||
assert_eq!(hint.as_deref(), Some("[arg]"));
|
||||
assert_eq!(body, "First line\r\nSecond line\r\n");
|
||||
}
|
||||
@@ -30,7 +30,6 @@ pub mod config_loader;
|
||||
pub mod connectors;
|
||||
mod context_manager;
|
||||
mod contextual_user_message;
|
||||
pub mod custom_prompts;
|
||||
pub use codex_utils_path::env;
|
||||
mod environment_context;
|
||||
pub mod error;
|
||||
|
||||
@@ -29,6 +29,7 @@ use super::sync_openai_plugins_repo;
|
||||
use crate::AuthManager;
|
||||
use crate::SkillMetadata;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigService;
|
||||
use crate::config::ConfigServiceError;
|
||||
@@ -1008,28 +1009,7 @@ impl PluginsManager {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) {
|
||||
if config.features.enabled(Feature::Plugins) {
|
||||
let mut configured_curated_plugin_ids =
|
||||
configured_plugins_from_stack(&config.config_layer_stack)
|
||||
.into_keys()
|
||||
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
|
||||
Ok(plugin_id)
|
||||
if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME =>
|
||||
{
|
||||
Some(plugin_id)
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin_key,
|
||||
error = %err,
|
||||
"ignoring invalid configured plugin key during curated sync setup"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
self.start_curated_repo_sync(configured_curated_plugin_ids);
|
||||
self.start_curated_repo_sync();
|
||||
start_startup_remote_plugin_sync_once(
|
||||
Arc::clone(self),
|
||||
self.codex_home.clone(),
|
||||
@@ -1054,7 +1034,7 @@ impl PluginsManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn start_curated_repo_sync(self: &Arc<Self>, configured_curated_plugin_ids: Vec<PluginId>) {
|
||||
fn start_curated_repo_sync(self: &Arc<Self>) {
|
||||
if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
@@ -1065,6 +1045,8 @@ impl PluginsManager {
|
||||
.spawn(
|
||||
move || match sync_openai_plugins_repo(codex_home.as_path()) {
|
||||
Ok(curated_plugin_version) => {
|
||||
let configured_curated_plugin_ids =
|
||||
configured_curated_plugin_ids_from_codex_home(codex_home.as_path());
|
||||
match refresh_curated_plugin_cache(
|
||||
codex_home.as_path(),
|
||||
&curated_plugin_version,
|
||||
@@ -1333,7 +1315,13 @@ fn configured_plugins_from_stack(
|
||||
let Some(user_layer) = config_layer_stack.get_user_layer() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Some(plugins_value) = user_layer.config.get("plugins") else {
|
||||
configured_plugins_from_user_config_value(&user_layer.config)
|
||||
}
|
||||
|
||||
fn configured_plugins_from_user_config_value(
|
||||
user_config: &toml::Value,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
let Some(plugins_value) = user_config.get("plugins") else {
|
||||
return HashMap::new();
|
||||
};
|
||||
match plugins_value.clone().try_into() {
|
||||
@@ -1345,6 +1333,60 @@ fn configured_plugins_from_stack(
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_curated_plugin_ids(
|
||||
configured_plugins: HashMap<String, PluginConfig>,
|
||||
) -> Vec<PluginId> {
|
||||
let mut configured_curated_plugin_ids = configured_plugins
|
||||
.into_keys()
|
||||
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
|
||||
Ok(plugin_id) if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME => {
|
||||
Some(plugin_id)
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin_key,
|
||||
error = %err,
|
||||
"ignoring invalid configured plugin key during curated sync setup"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
configured_curated_plugin_ids
|
||||
}
|
||||
|
||||
fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec<PluginId> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let user_config = match fs::read_to_string(&config_path) {
|
||||
Ok(user_config) => user_config,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %config_path.display(),
|
||||
error = %err,
|
||||
"failed to read user config while refreshing curated plugin cache"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let user_config = match toml::from_str::<toml::Value>(&user_config) {
|
||||
Ok(user_config) => user_config,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %config_path.display(),
|
||||
error = %err,
|
||||
"failed to parse user config while refreshing curated plugin cache"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
configured_curated_plugin_ids(configured_plugins_from_user_config_value(&user_config))
|
||||
}
|
||||
|
||||
fn load_plugin(
|
||||
config_name: String,
|
||||
plugin: &PluginConfig,
|
||||
|
||||
@@ -2207,6 +2207,43 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_curated_plugin_ids_from_codex_home_reads_latest_user_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."slack@openai-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."sample@debug"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
configured_curated_plugin_ids_from_codex_home(tmp.path())
|
||||
.into_iter()
|
||||
.map(|plugin_id| plugin_id.as_key())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["slack@openai-curated".to_string()]
|
||||
);
|
||||
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
configured_curated_plugin_ids_from_codex_home(tmp.path()),
|
||||
Vec::<PluginId>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -19,7 +19,6 @@ use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -29,6 +28,7 @@ use crate::tools::router::ToolCallSource;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::unified_exec::resolve_max_tokens;
|
||||
use codex_features::Feature;
|
||||
use codex_tools::tool_spec_to_code_mode_tool_definition;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
|
||||
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
|
||||
@@ -247,42 +247,13 @@ pub(super) async fn build_enabled_tools(
|
||||
let mut out = router
|
||||
.specs()
|
||||
.into_iter()
|
||||
.map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true))
|
||||
.filter_map(enabled_tool_from_spec)
|
||||
.filter_map(|spec| tool_spec_to_code_mode_tool_definition(&spec))
|
||||
.collect::<Vec<_>>();
|
||||
out.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
out.dedup_by(|left, right| left.name == right.name);
|
||||
out
|
||||
}
|
||||
|
||||
fn enabled_tool_from_spec(spec: ToolSpec) -> Option<codex_code_mode::ToolDefinition> {
|
||||
let tool_name = spec.name().to_string();
|
||||
if !codex_code_mode::is_code_mode_nested_tool(&tool_name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match spec {
|
||||
ToolSpec::Function(tool) => Some(codex_code_mode::ToolDefinition {
|
||||
name: tool_name,
|
||||
description: tool.description,
|
||||
kind: codex_code_mode::CodeModeToolKind::Function,
|
||||
input_schema: serde_json::to_value(&tool.parameters).ok(),
|
||||
output_schema: tool.output_schema,
|
||||
}),
|
||||
ToolSpec::Freeform(tool) => Some(codex_code_mode::ToolDefinition {
|
||||
name: tool_name,
|
||||
description: tool.description,
|
||||
kind: codex_code_mode::CodeModeToolKind::Freeform,
|
||||
input_schema: None,
|
||||
output_schema: None,
|
||||
}),
|
||||
ToolSpec::LocalShell {}
|
||||
| ToolSpec::ImageGeneration { .. }
|
||||
| ToolSpec::ToolSearch { .. }
|
||||
| ToolSpec::WebSearch { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
#[cfg(test)]
|
||||
pub(crate) use codex_code_mode::append_code_mode_sample;
|
||||
#[allow(unused_imports)]
|
||||
#[cfg(test)]
|
||||
pub(crate) use codex_code_mode::render_json_schema_to_typescript;
|
||||
|
||||
pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: bool) -> ToolSpec {
|
||||
if !code_mode_enabled {
|
||||
return spec;
|
||||
}
|
||||
|
||||
match spec {
|
||||
ToolSpec::Function(mut tool) => {
|
||||
let input_type = serde_json::to_value(&tool.parameters)
|
||||
.ok()
|
||||
.map(|schema| codex_code_mode::render_json_schema_to_typescript(&schema))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let output_type = tool
|
||||
.output_schema
|
||||
.as_ref()
|
||||
.map(codex_code_mode::render_json_schema_to_typescript)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
tool.description = codex_code_mode::append_code_mode_sample(
|
||||
&tool.description,
|
||||
&tool.name,
|
||||
"args",
|
||||
input_type,
|
||||
output_type,
|
||||
);
|
||||
ToolSpec::Function(tool)
|
||||
}
|
||||
ToolSpec::Freeform(mut tool) => {
|
||||
if tool.name != codex_code_mode::PUBLIC_TOOL_NAME {
|
||||
tool.description = codex_code_mode::append_code_mode_sample(
|
||||
&tool.description,
|
||||
&tool.name,
|
||||
"input",
|
||||
"string".to_string(),
|
||||
"unknown".to_string(),
|
||||
);
|
||||
}
|
||||
ToolSpec::Freeform(tool)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
use super::append_code_mode_sample;
|
||||
use super::render_json_schema_to_typescript;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn render_json_schema_to_typescript_renders_object_properties() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"recursive": {"type": "boolean"}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
render_json_schema_to_typescript(&schema),
|
||||
"{ path: string; recursive?: boolean; }"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_json_schema_to_typescript_renders_anyof_unions() {
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{"const": "pending"},
|
||||
{"const": "done"},
|
||||
{"type": "number"}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
render_json_schema_to_typescript(&schema),
|
||||
"\"pending\" | \"done\" | number"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_json_schema_to_typescript_renders_additional_properties() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"additionalProperties": {"type": "integer"}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
render_json_schema_to_typescript(&schema),
|
||||
"{ tags?: Array<string>; [key: string]: number; }"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_json_schema_to_typescript_sorts_object_properties() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"structuredContent": {"type": "string"},
|
||||
"_meta": {"type": "string"},
|
||||
"isError": {"type": "boolean"},
|
||||
"content": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"required": ["content"]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
render_json_schema_to_typescript(&schema),
|
||||
"{ _meta?: string; content: Array<string>; isError?: boolean; structuredContent?: string; }"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_code_mode_sample_uses_global_tools_for_valid_identifiers() {
|
||||
assert_eq!(
|
||||
append_code_mode_sample(
|
||||
"desc",
|
||||
"mcp__ologs__get_profile",
|
||||
"args",
|
||||
"{ foo: string }".to_string(),
|
||||
"unknown".to_string(),
|
||||
),
|
||||
"desc\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__ologs__get_profile(args: { foo: string }): Promise<unknown>; };\n```"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_code_mode_sample_normalizes_invalid_identifiers() {
|
||||
assert_eq!(
|
||||
append_code_mode_sample(
|
||||
"desc",
|
||||
"mcp__rmcp__echo-tool",
|
||||
"args",
|
||||
"{ foo: string }".to_string(),
|
||||
"unknown".to_string(),
|
||||
),
|
||||
"desc\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__rmcp__echo_tool(args: { foo: string }): Promise<unknown>; };\n```"
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod code_mode;
|
||||
pub(crate) mod code_mode_description;
|
||||
pub mod context;
|
||||
pub(crate) mod discoverable;
|
||||
pub mod events;
|
||||
|
||||
@@ -24,6 +24,7 @@ use codex_hooks::HookToolInput;
|
||||
use codex_hooks::HookToolInputLocalShell;
|
||||
use codex_hooks::HookToolKind;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
@@ -436,21 +437,6 @@ impl ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfiguredToolSpec {
|
||||
pub spec: ToolSpec,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
impl ConfiguredToolSpec {
|
||||
pub fn new(spec: ToolSpec, supports_parallel_tool_calls: bool) -> Self {
|
||||
Self {
|
||||
spec,
|
||||
supports_parallel_tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ToolRegistryBuilder {
|
||||
handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
|
||||
specs: Vec<ConfiguredToolSpec>,
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::registry::AnyToolResult;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::build_specs_with_discoverable_tools;
|
||||
@@ -18,6 +17,7 @@ use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use rmcp::model::Tool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -66,7 +66,7 @@ impl ToolRouter {
|
||||
specs
|
||||
.iter()
|
||||
.filter_map(|configured_tool| {
|
||||
if !codex_code_mode::is_code_mode_nested_tool(configured_tool.spec.name()) {
|
||||
if !codex_code_mode::is_code_mode_nested_tool(configured_tool.name()) {
|
||||
Some(configured_tool.spec.clone())
|
||||
} else {
|
||||
None
|
||||
@@ -101,7 +101,7 @@ impl ToolRouter {
|
||||
pub fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
|
||||
self.specs
|
||||
.iter()
|
||||
.find(|config| config.spec.name() == tool_name)
|
||||
.find(|config| config.name() == tool_name)
|
||||
.map(|config| config.spec.clone())
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ impl ToolRouter {
|
||||
self.specs
|
||||
.iter()
|
||||
.filter(|config| config.supports_parallel_tool_calls)
|
||||
.any(|config| config.spec.name() == tool_name)
|
||||
.any(|config| config.name() == tool_name)
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all, err)]
|
||||
|
||||
+31
-550
@@ -8,7 +8,6 @@ use crate::shell::Shell;
|
||||
use crate::shell::ShellType;
|
||||
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
|
||||
use crate::tools::code_mode::WAIT_TOOL_NAME;
|
||||
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
|
||||
use crate::tools::discoverable::DiscoverablePluginInfo;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::discoverable::DiscoverableToolAction;
|
||||
@@ -33,7 +32,6 @@ use codex_protocol::config_types::WebSearchConfig;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
|
||||
use codex_protocol::openai_models::ApplyPatchToolType;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
@@ -43,11 +41,22 @@ use codex_protocol::openai_models::WebSearchToolType;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_tools::CommandToolOptions;
|
||||
use codex_tools::FreeformTool;
|
||||
use codex_tools::FreeformToolFormat;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ShellToolOptions;
|
||||
use codex_tools::ViewImageToolOptions;
|
||||
use codex_tools::augment_tool_spec_for_code_mode;
|
||||
use codex_tools::create_exec_command_tool;
|
||||
use codex_tools::create_request_permissions_tool;
|
||||
use codex_tools::create_shell_command_tool;
|
||||
use codex_tools::create_shell_tool;
|
||||
use codex_tools::create_view_image_tool;
|
||||
use codex_tools::create_write_stdin_tool;
|
||||
use codex_tools::dynamic_tool_to_responses_api_tool;
|
||||
use codex_tools::mcp_tool_to_responses_api_tool;
|
||||
use codex_tools::tool_spec_to_code_mode_tool_definition;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_template::Template;
|
||||
use serde::Deserialize;
|
||||
@@ -80,40 +89,6 @@ static TOOL_SUGGEST_DESCRIPTION_TEMPLATE: LazyLock<Template> = LazyLock::new(||
|
||||
});
|
||||
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
|
||||
|
||||
fn unified_exec_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chunk_id": {
|
||||
"type": "string",
|
||||
"description": "Chunk identifier included when the response reports one."
|
||||
},
|
||||
"wall_time_seconds": {
|
||||
"type": "number",
|
||||
"description": "Elapsed wall time spent waiting for output in seconds."
|
||||
},
|
||||
"exit_code": {
|
||||
"type": "number",
|
||||
"description": "Process exit code when the command finished during this call."
|
||||
},
|
||||
"session_id": {
|
||||
"type": "number",
|
||||
"description": "Session identifier to pass to write_stdin when the process is still running."
|
||||
},
|
||||
"original_token_count": {
|
||||
"type": "number",
|
||||
"description": "Approximate token count before output truncation."
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "Command output text, possibly truncated."
|
||||
}
|
||||
},
|
||||
"required": ["wall_time_seconds", "output"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_status_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"oneOf": [
|
||||
@@ -558,267 +533,6 @@ fn supports_image_generation(model_info: &ModelInfo) -> bool {
|
||||
model_info.input_modalities.contains(&InputModality::Image)
|
||||
}
|
||||
|
||||
fn create_network_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"enabled".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Set to true to request network access.".to_string()),
|
||||
},
|
||||
)]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file_system_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"read".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("Absolute paths to grant read access to.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"write".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("Absolute paths to grant write access to.".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_additional_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
("network".to_string(), create_network_permissions_schema()),
|
||||
(
|
||||
"file_system".to_string(),
|
||||
create_file_system_permissions_schema(),
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_request_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
("network".to_string(), create_network_permissions_schema()),
|
||||
(
|
||||
"file_system".to_string(),
|
||||
create_file_system_permissions_schema(),
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_destructive_filesystem_guidance() -> &'static str {
|
||||
r#"Windows safety rules:
|
||||
- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.
|
||||
- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked."#
|
||||
}
|
||||
|
||||
fn create_approval_parameters(
|
||||
exec_permission_approvals_enabled: bool,
|
||||
) -> BTreeMap<String, JsonSchema> {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"sandbox_permissions".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
if exec_permission_approvals_enabled {
|
||||
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
} else {
|
||||
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
r#"Only set if sandbox_permissions is \"require_escalated\".
|
||||
Request approval from the user to run this command outside the sandbox.
|
||||
Phrased as a simple question that summarizes the purpose of the
|
||||
command as it relates to the task at hand - e.g. 'Do you want to
|
||||
fetch and pull the latest version of this git branch?'"#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"prefix_rule".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
r#"Only specify when sandbox_permissions is `require_escalated`.
|
||||
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
|
||||
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(),
|
||||
),
|
||||
},
|
||||
)
|
||||
]);
|
||||
|
||||
if exec_permission_approvals_enabled {
|
||||
properties.insert(
|
||||
"additional_permissions".to_string(),
|
||||
create_additional_permissions_schema(),
|
||||
);
|
||||
}
|
||||
|
||||
properties
|
||||
}
|
||||
|
||||
fn create_exec_command_tool(
|
||||
allow_login_shell: bool,
|
||||
exec_permission_approvals_enabled: bool,
|
||||
) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"cmd".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Shell command to execute.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional working directory to run the command in; defaults to the turn cwd."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"shell".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Shell binary to launch. Defaults to the user's default shell.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"tty".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
if allow_login_shell {
|
||||
properties.insert(
|
||||
"login".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
properties.extend(create_approval_parameters(
|
||||
exec_permission_approvals_enabled,
|
||||
));
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "exec_command".to_string(),
|
||||
description: if cfg!(windows) {
|
||||
format!(
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n\n{}",
|
||||
windows_destructive_filesystem_guidance()
|
||||
)
|
||||
} else {
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
|
||||
.to_string()
|
||||
},
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["cmd".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(unified_exec_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_write_stdin_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"session_id".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Identifier of the running unified exec session.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"chars".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "write_stdin".to_string(),
|
||||
description:
|
||||
"Writes characters to an existing unified exec session and returns recent output."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["session_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(unified_exec_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_wait_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
@@ -869,194 +583,6 @@ fn create_wait_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_shell_tool(exec_permission_approvals_enabled: bool) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("The command to execute".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
properties.extend(create_approval_parameters(
|
||||
exec_permission_approvals_enabled,
|
||||
));
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
format!(
|
||||
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
|
||||
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
|
||||
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
|
||||
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"]
|
||||
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
|
||||
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]
|
||||
|
||||
{}"#,
|
||||
windows_destructive_filesystem_guidance()
|
||||
)
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
|
||||
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
|
||||
.to_string()
|
||||
};
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_shell_command_tool(
|
||||
allow_login_shell: bool,
|
||||
exec_permission_approvals_enabled: bool,
|
||||
) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"The shell script to execute in the user's default shell".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
if allow_login_shell {
|
||||
properties.insert(
|
||||
"login".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to run the shell with login shell semantics. Defaults to true."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
properties.extend(create_approval_parameters(
|
||||
exec_permission_approvals_enabled,
|
||||
));
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
format!(
|
||||
r#"Runs a Powershell command (Windows) and returns its output.
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): "Get-ChildItem -Force"
|
||||
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
|
||||
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
|
||||
- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"
|
||||
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
|
||||
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"
|
||||
|
||||
{}"#,
|
||||
windows_destructive_filesystem_guidance()
|
||||
)
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
|
||||
.to_string()
|
||||
};
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell_command".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_view_image_tool(can_request_original_image_detail: bool) -> ToolSpec {
|
||||
// Support only local filesystem path.
|
||||
let mut properties = BTreeMap::from([(
|
||||
"path".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Local filesystem path to an image file".to_string()),
|
||||
},
|
||||
)]);
|
||||
if can_request_original_image_detail {
|
||||
properties.insert(
|
||||
"detail".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: VIEW_IMAGE_TOOL_NAME.to_string(),
|
||||
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 <image ...> tags)."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_url": {
|
||||
"type": "string",
|
||||
"description": "Data URL for the loaded image."
|
||||
},
|
||||
"detail": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`."
|
||||
}
|
||||
},
|
||||
"required": ["image_url", "detail"],
|
||||
"additionalProperties": false
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_collab_input_items_schema() -> JsonSchema {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
@@ -1750,35 +1276,6 @@ fn create_request_user_input_tool(
|
||||
})
|
||||
}
|
||||
|
||||
fn create_request_permissions_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"reason".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional short explanation for why additional permissions are needed.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"permissions".to_string(),
|
||||
create_request_permissions_schema(),
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "request_permissions".to_string(),
|
||||
description: request_permissions_tool_description(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["permissions".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_close_agent_tool_v1() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
@@ -2351,29 +1848,17 @@ pub(crate) struct ApplyPatchToolArgs {
|
||||
pub(crate) input: String,
|
||||
}
|
||||
|
||||
/// Returns JSON values that are compatible with Function Calling in the
|
||||
/// Responses API:
|
||||
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
|
||||
pub fn create_tools_json_for_responses_api(
|
||||
tools: &[ToolSpec],
|
||||
) -> crate::error::Result<Vec<serde_json::Value>> {
|
||||
let mut tools_json = Vec::new();
|
||||
|
||||
for tool in tools {
|
||||
let json = serde_json::to_value(tool)?;
|
||||
tools_json.push(json);
|
||||
}
|
||||
|
||||
Ok(tools_json)
|
||||
}
|
||||
|
||||
fn push_tool_spec(
|
||||
builder: &mut ToolRegistryBuilder,
|
||||
spec: ToolSpec,
|
||||
supports_parallel_tool_calls: bool,
|
||||
code_mode_enabled: bool,
|
||||
) {
|
||||
let spec = augment_tool_spec_for_code_mode(spec, code_mode_enabled);
|
||||
let spec = if code_mode_enabled {
|
||||
augment_tool_spec_for_code_mode(spec)
|
||||
} else {
|
||||
spec
|
||||
};
|
||||
if supports_parallel_tool_calls {
|
||||
builder.push_spec_with_parallel_support(spec, /*supports_parallel_tool_calls*/ true);
|
||||
} else {
|
||||
@@ -2471,16 +1956,8 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
.build();
|
||||
let mut enabled_tools = nested_specs
|
||||
.into_iter()
|
||||
.filter_map(|spec| {
|
||||
let (name, description) = match augment_tool_spec_for_code_mode(
|
||||
spec.spec, /*code_mode_enabled*/ true,
|
||||
) {
|
||||
ToolSpec::Function(tool) => (tool.name, tool.description),
|
||||
ToolSpec::Freeform(tool) => (tool.name, tool.description),
|
||||
_ => return None,
|
||||
};
|
||||
codex_code_mode::is_code_mode_nested_tool(&name).then_some((name, description))
|
||||
})
|
||||
.filter_map(|spec| tool_spec_to_code_mode_tool_definition(&spec.spec))
|
||||
.map(|tool| (tool.name, tool.description))
|
||||
.collect::<Vec<_>>();
|
||||
enabled_tools.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
enabled_tools.dedup_by(|left, right| left.0 == right.0);
|
||||
@@ -2504,7 +1981,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
ConfigShellToolType::Default => {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_shell_tool(exec_permission_approvals_enabled),
|
||||
create_shell_tool(ShellToolOptions {
|
||||
exec_permission_approvals_enabled,
|
||||
}),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
@@ -2520,10 +1999,10 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
ConfigShellToolType::UnifiedExec => {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_exec_command_tool(
|
||||
config.allow_login_shell,
|
||||
create_exec_command_tool(CommandToolOptions {
|
||||
allow_login_shell: config.allow_login_shell,
|
||||
exec_permission_approvals_enabled,
|
||||
),
|
||||
}),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
@@ -2542,10 +2021,10 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
ConfigShellToolType::ShellCommand => {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_shell_command_tool(
|
||||
config.allow_login_shell,
|
||||
create_shell_command_tool(CommandToolOptions {
|
||||
allow_login_shell: config.allow_login_shell,
|
||||
exec_permission_approvals_enabled,
|
||||
),
|
||||
}),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
@@ -2624,7 +2103,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
if config.request_permissions_tool_enabled {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_request_permissions_tool(),
|
||||
create_request_permissions_tool(request_permissions_tool_description()),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
@@ -2767,7 +2246,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_view_image_tool(config.can_request_original_image_detail),
|
||||
create_view_image_tool(ViewImageToolOptions {
|
||||
can_request_original_image_detail: config.can_request_original_image_detail,
|
||||
}),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
use crate::client_common::tools::FreeformTool;
|
||||
use crate::config::test_config;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::models_manager::model_info::with_config_overrides;
|
||||
use crate::shell::Shell;
|
||||
use crate::shell::ShellType;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_tools::AdditionalProperties;
|
||||
use codex_tools::CommandToolOptions;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_tools::FreeformTool;
|
||||
use codex_tools::ResponsesApiWebSearchFilters;
|
||||
use codex_tools::ResponsesApiWebSearchUserLocation;
|
||||
use codex_tools::ViewImageToolOptions;
|
||||
use codex_tools::create_exec_command_tool;
|
||||
use codex_tools::create_request_permissions_tool;
|
||||
use codex_tools::create_view_image_tool;
|
||||
use codex_tools::create_write_stdin_tool;
|
||||
use codex_tools::mcp_tool_to_deferred_responses_api_tool;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -52,10 +61,6 @@ fn discoverable_connector(id: &str, name: &str, description: &str) -> Discoverab
|
||||
}))
|
||||
}
|
||||
|
||||
fn windows_shell_safety_description() -> String {
|
||||
format!("\n\n{}", super::windows_destructive_filesystem_guidance())
|
||||
}
|
||||
|
||||
fn search_capable_model_info() -> ModelInfo {
|
||||
let config = test_config();
|
||||
let mut model_info =
|
||||
@@ -105,23 +110,12 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
);
|
||||
}
|
||||
|
||||
fn tool_name(tool: &ToolSpec) -> &str {
|
||||
match tool {
|
||||
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
|
||||
ToolSpec::ToolSearch { .. } => "tool_search",
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration { .. } => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
ToolSpec::Freeform(FreeformTool { name, .. }) => name,
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid order-based assertions; compare via set containment instead.
|
||||
fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&str]) {
|
||||
use std::collections::HashSet;
|
||||
let mut names = HashSet::new();
|
||||
let mut duplicates = Vec::new();
|
||||
for name in tools.iter().map(|t| tool_name(&t.spec)) {
|
||||
for name in tools.iter().map(ConfiguredToolSpec::name) {
|
||||
if !names.insert(name) {
|
||||
duplicates.push(name);
|
||||
}
|
||||
@@ -141,7 +135,7 @@ fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&
|
||||
fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) {
|
||||
let names = tools
|
||||
.iter()
|
||||
.map(|tool| tool_name(&tool.spec))
|
||||
.map(ConfiguredToolSpec::name)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!names.contains(&expected_absent),
|
||||
@@ -162,7 +156,7 @@ fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> {
|
||||
fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a ConfiguredToolSpec {
|
||||
tools
|
||||
.iter()
|
||||
.find(|tool| tool_name(&tool.spec) == expected_name)
|
||||
.find(|tool| tool.name() == expected_name)
|
||||
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
|
||||
}
|
||||
|
||||
@@ -294,7 +288,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
let mut actual: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
|
||||
let mut duplicate_names = Vec::new();
|
||||
for t in &tools {
|
||||
let name = tool_name(&t.spec).to_string();
|
||||
let name = t.name().to_string();
|
||||
if actual.insert(name.clone(), t.spec.clone()).is_some() {
|
||||
duplicate_names.push(name);
|
||||
}
|
||||
@@ -307,9 +301,10 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
// Build expected from the same helpers used by the builder.
|
||||
let mut expected: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
|
||||
for spec in [
|
||||
create_exec_command_tool(
|
||||
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
|
||||
),
|
||||
create_exec_command_tool(CommandToolOptions {
|
||||
allow_login_shell: true,
|
||||
exec_permission_approvals_enabled: false,
|
||||
}),
|
||||
create_write_stdin_tool(),
|
||||
PLAN_TOOL.clone(),
|
||||
create_request_user_input_tool(CollaborationModesConfig::default()),
|
||||
@@ -321,9 +316,11 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
search_context_size: None,
|
||||
search_content_types: None,
|
||||
},
|
||||
create_view_image_tool(config.can_request_original_image_detail),
|
||||
create_view_image_tool(ViewImageToolOptions {
|
||||
can_request_original_image_detail: config.can_request_original_image_detail,
|
||||
}),
|
||||
] {
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
expected.insert(spec.name().to_string(), spec);
|
||||
}
|
||||
let collab_specs = if config.multi_agent_v2 {
|
||||
vec![
|
||||
@@ -341,16 +338,16 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
]
|
||||
};
|
||||
for spec in collab_specs {
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
expected.insert(spec.name().to_string(), spec);
|
||||
}
|
||||
if !config.multi_agent_v2 {
|
||||
let spec = create_resume_agent_tool();
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
expected.insert(spec.name().to_string(), spec);
|
||||
}
|
||||
|
||||
if config.exec_permission_approvals_enabled {
|
||||
let spec = create_request_permissions_tool();
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
let spec = create_request_permissions_tool(request_permissions_tool_description());
|
||||
expected.insert(spec.name().to_string(), spec);
|
||||
}
|
||||
|
||||
// Exact name set match — this is the only test allowed to fail when tools change.
|
||||
@@ -813,7 +810,7 @@ fn request_permissions_requires_feature_flag() {
|
||||
let request_permissions_tool = find_tool(&tools, "request_permissions");
|
||||
assert_eq!(
|
||||
request_permissions_tool.spec,
|
||||
create_request_permissions_tool()
|
||||
create_request_permissions_tool(request_permissions_tool_description())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1205,10 +1202,10 @@ fn web_search_config_is_forwarded_to_tool_spec() {
|
||||
external_web_access: Some(true),
|
||||
filters: web_search_config
|
||||
.filters
|
||||
.map(crate::client_common::tools::ResponsesApiWebSearchFilters::from),
|
||||
.map(ResponsesApiWebSearchFilters::from),
|
||||
user_location: web_search_config
|
||||
.user_location
|
||||
.map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from),
|
||||
.map(ResponsesApiWebSearchUserLocation::from),
|
||||
search_context_size: web_search_config.search_context_size,
|
||||
search_content_types: None,
|
||||
}
|
||||
@@ -1684,11 +1681,7 @@ fn test_test_model_info_includes_sync_tool() {
|
||||
)
|
||||
.build();
|
||||
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool_name(&tool.spec) == "test_sync_tool")
|
||||
);
|
||||
assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1822,7 +1815,7 @@ fn test_build_specs_mcp_tools_sorted_by_name() {
|
||||
// Only assert that the MCP tools themselves are sorted by fully-qualified name.
|
||||
let mcp_names: Vec<_> = tools
|
||||
.iter()
|
||||
.map(|t| tool_name(&t.spec).to_string())
|
||||
.map(|t| t.name().to_string())
|
||||
.filter(|n| n.starts_with("test_server/"))
|
||||
.collect();
|
||||
let expected = vec![
|
||||
@@ -2069,7 +2062,7 @@ fn tool_suggest_is_not_registered_without_feature_flag() {
|
||||
assert!(
|
||||
!tools
|
||||
.iter()
|
||||
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME)
|
||||
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2160,7 +2153,7 @@ fn tool_suggest_requires_apps_and_plugins_features() {
|
||||
assert!(
|
||||
!tools
|
||||
.iter()
|
||||
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME),
|
||||
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME),
|
||||
"tool_suggest should be absent when {disabled_feature:?} is disabled"
|
||||
);
|
||||
}
|
||||
@@ -2653,177 +2646,6 @@ fn test_mcp_tool_anyof_defaults_to_string() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_tool() {
|
||||
let tool = super::create_shell_tool(/*exec_permission_approvals_enabled*/ false);
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description, name, ..
|
||||
}) = &tool
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert_eq!(name, "shell");
|
||||
|
||||
let expected = if cfg!(windows) {
|
||||
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
|
||||
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
|
||||
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
|
||||
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
|
||||
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
|
||||
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"#
|
||||
.to_string()
|
||||
+ &windows_shell_safety_description()
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
|
||||
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(description, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exec_command_tool_windows_description_includes_shell_safety_guidance() {
|
||||
let tool = super::create_exec_command_tool(
|
||||
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
|
||||
);
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description, name, ..
|
||||
}) = &tool
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert_eq!(name, "exec_command");
|
||||
|
||||
let expected = if cfg!(windows) {
|
||||
format!(
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.{}",
|
||||
windows_shell_safety_description()
|
||||
)
|
||||
} else {
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(description, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tool_with_request_permission_includes_additional_permissions() {
|
||||
let tool = super::create_shell_tool(/*exec_permission_approvals_enabled*/ true);
|
||||
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
let JsonSchema::Object { properties, .. } = parameters else {
|
||||
panic!("expected object parameters");
|
||||
};
|
||||
|
||||
assert!(properties.contains_key("additional_permissions"));
|
||||
|
||||
let Some(JsonSchema::String {
|
||||
description: Some(description),
|
||||
}) = properties.get("sandbox_permissions")
|
||||
else {
|
||||
panic!("expected sandbox_permissions description");
|
||||
};
|
||||
assert!(description.contains("with_additional_permissions"));
|
||||
assert!(description.contains("filesystem or network permissions"));
|
||||
|
||||
let Some(JsonSchema::Object {
|
||||
properties: additional_properties,
|
||||
..
|
||||
}) = properties.get("additional_permissions")
|
||||
else {
|
||||
panic!("expected additional_permissions schema");
|
||||
};
|
||||
assert!(additional_properties.contains_key("network"));
|
||||
assert!(additional_properties.contains_key("file_system"));
|
||||
assert!(!additional_properties.contains_key("macos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_permissions_tool_includes_full_permission_schema() {
|
||||
let tool = super::create_request_permissions_tool();
|
||||
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
let JsonSchema::Object { properties, .. } = parameters else {
|
||||
panic!("expected object parameters");
|
||||
};
|
||||
let Some(JsonSchema::Object {
|
||||
properties: permission_properties,
|
||||
additional_properties,
|
||||
..
|
||||
}) = properties.get("permissions")
|
||||
else {
|
||||
panic!("expected permissions object");
|
||||
};
|
||||
|
||||
assert_eq!(additional_properties, &Some(false.into()));
|
||||
assert!(permission_properties.contains_key("network"));
|
||||
assert!(permission_properties.contains_key("file_system"));
|
||||
assert!(!permission_properties.contains_key("macos"));
|
||||
|
||||
let Some(JsonSchema::Object {
|
||||
properties: network_properties,
|
||||
additional_properties,
|
||||
..
|
||||
}) = permission_properties.get("network")
|
||||
else {
|
||||
panic!("expected network object");
|
||||
};
|
||||
assert_eq!(additional_properties, &Some(false.into()));
|
||||
assert!(network_properties.contains_key("enabled"));
|
||||
|
||||
let Some(JsonSchema::Object {
|
||||
properties: file_system_properties,
|
||||
additional_properties,
|
||||
..
|
||||
}) = permission_properties.get("file_system")
|
||||
else {
|
||||
panic!("expected file_system object");
|
||||
};
|
||||
assert_eq!(additional_properties, &Some(false.into()));
|
||||
assert!(file_system_properties.contains_key("read"));
|
||||
assert!(file_system_properties.contains_key("write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_command_tool() {
|
||||
let tool = super::create_shell_command_tool(
|
||||
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
|
||||
);
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description, name, ..
|
||||
}) = &tool
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert_eq!(name, "shell_command");
|
||||
|
||||
let expected = if cfg!(windows) {
|
||||
r#"Runs a Powershell command (Windows) and returns its output.
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): "Get-ChildItem -Force"
|
||||
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
|
||||
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
|
||||
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
|
||||
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
|
||||
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""#
|
||||
.to_string()
|
||||
+ &windows_shell_safety_description()
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#.to_string()
|
||||
};
|
||||
assert_eq!(description, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
let config = test_config();
|
||||
@@ -3111,38 +2933,3 @@ fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only(
|
||||
assert!(!description.contains("### `update_plan` (`update_plan`)"));
|
||||
assert!(!description.contains("### `view_image` (`view_image`)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_tools_include_top_level_name() {
|
||||
let properties =
|
||||
BTreeMap::from([("foo".to_string(), JsonSchema::String { description: None })]);
|
||||
let tools = vec![ToolSpec::Function(ResponsesApiTool {
|
||||
name: "demo".to_string(),
|
||||
description: "A demo tool".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
})];
|
||||
|
||||
let responses_json = create_tools_json_for_responses_api(&tools).unwrap();
|
||||
assert_eq!(
|
||||
responses_json,
|
||||
vec![json!({
|
||||
"type": "function",
|
||||
"name": "demo",
|
||||
"description": "A demo tool",
|
||||
"strict": false,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foo": { "type": "string" }
|
||||
},
|
||||
},
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1975,10 +1975,14 @@ mod tests {
|
||||
let cwd = tempdir().expect("create temp cwd");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
..Default::default()
|
||||
})
|
||||
.fallback_cwd(Some(cwd.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("build default config");
|
||||
.expect("build config with manual-only review policy");
|
||||
|
||||
let params = thread_start_params_from_config(&config);
|
||||
|
||||
@@ -1992,17 +1996,16 @@ mod tests {
|
||||
async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() {
|
||||
let codex_home = tempdir().expect("create temp codex home");
|
||||
let cwd = tempdir().expect("create temp cwd");
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"approvals_reviewer = \"guardian_subagent\"\n",
|
||||
)
|
||||
.expect("write auto-review config");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent),
|
||||
..Default::default()
|
||||
})
|
||||
.fallback_cwd(Some(cwd.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("build auto-review config");
|
||||
.expect("build config with guardian review policy");
|
||||
|
||||
let params = thread_start_params_from_config(&config);
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@ async fn spawn_command_under_sandbox(
|
||||
command_cwd,
|
||||
sandbox_policy,
|
||||
sandbox_cwd,
|
||||
false,
|
||||
/*use_legacy_landlock*/ false,
|
||||
stdio_policy,
|
||||
None,
|
||||
/*network*/ None,
|
||||
env,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -45,7 +45,7 @@ const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in t
|
||||
|
||||
fn create_env_from_core_vars() -> HashMap<String, String> {
|
||||
let policy = ShellEnvironmentPolicy::default();
|
||||
create_env(&policy, None)
|
||||
create_env(&policy, /*thread_id*/ None)
|
||||
}
|
||||
|
||||
#[expect(clippy::print_stdout)]
|
||||
@@ -64,9 +64,15 @@ async fn run_cmd_output(
|
||||
writable_roots: &[PathBuf],
|
||||
timeout_ms: u64,
|
||||
) -> codex_core::exec::ExecToolCallOutput {
|
||||
run_cmd_result_with_writable_roots(cmd, writable_roots, timeout_ms, false, false)
|
||||
.await
|
||||
.expect("sandboxed command should execute")
|
||||
run_cmd_result_with_writable_roots(
|
||||
cmd,
|
||||
writable_roots,
|
||||
timeout_ms,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("sandboxed command should execute")
|
||||
}
|
||||
|
||||
async fn run_cmd_result_with_writable_roots(
|
||||
@@ -137,7 +143,7 @@ async fn run_cmd_result_with_policies(
|
||||
sandbox_cwd.as_path(),
|
||||
&codex_linux_sandbox_exe,
|
||||
use_legacy_landlock,
|
||||
None,
|
||||
/*stdout_stream*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -158,8 +164,8 @@ async fn should_skip_bwrap_tests() -> bool {
|
||||
&["bash", "-lc", "true"],
|
||||
&[],
|
||||
NETWORK_TIMEOUT_MS,
|
||||
false,
|
||||
true,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -219,8 +225,8 @@ async fn test_dev_null_write() {
|
||||
// We have seen timeouts when running this test in CI on GitHub,
|
||||
// so we are using a generous timeout until we can diagnose further.
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
true,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await
|
||||
.expect("sandboxed command should execute");
|
||||
@@ -243,8 +249,8 @@ async fn bwrap_populates_minimal_dev_nodes() {
|
||||
],
|
||||
&[],
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
true,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await
|
||||
.expect("sandboxed command should execute");
|
||||
@@ -281,8 +287,8 @@ async fn bwrap_preserves_writable_dev_shm_bind_mount() {
|
||||
],
|
||||
&[PathBuf::from("/dev/shm")],
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
true,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await
|
||||
.expect("sandboxed command should execute");
|
||||
@@ -328,8 +334,8 @@ async fn sandbox_ignores_missing_writable_roots_under_bwrap() {
|
||||
&["bash", "-lc", "printf sandbox-ok"],
|
||||
&[existing_root, missing_root],
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
true,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await
|
||||
.expect("sandboxed command should execute");
|
||||
@@ -360,7 +366,7 @@ async fn test_no_new_privs_is_enabled() {
|
||||
#[tokio::test]
|
||||
#[should_panic(expected = "Sandbox(Timeout")]
|
||||
async fn test_timeout() {
|
||||
run_cmd(&["sleep", "2"], &[], 50).await;
|
||||
run_cmd(&["sleep", "2"], &[], /*timeout_ms*/ 50).await;
|
||||
}
|
||||
|
||||
/// Helper that runs `cmd` under the Linux sandbox and asserts that the command
|
||||
@@ -397,8 +403,8 @@ async fn assert_network_blocked(cmd: &[&str]) {
|
||||
NetworkSandboxPolicy::from(&sandbox_policy),
|
||||
sandbox_cwd.as_path(),
|
||||
&codex_linux_sandbox_exe,
|
||||
false,
|
||||
None,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*stdout_stream*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -473,8 +479,8 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
true,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await,
|
||||
".git write should be denied under bubblewrap",
|
||||
@@ -489,8 +495,8 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
true,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await,
|
||||
".codex write should be denied under bubblewrap",
|
||||
@@ -526,8 +532,8 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() {
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
true,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await,
|
||||
".codex symlink replacement should be denied",
|
||||
@@ -598,7 +604,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
|
||||
file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
/*use_legacy_landlock*/ false,
|
||||
)
|
||||
.await,
|
||||
"explicit split-policy carveout should be denied under bubblewrap",
|
||||
@@ -680,7 +686,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() {
|
||||
file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
/*use_legacy_landlock*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("nested writable carveout should execute under bubblewrap");
|
||||
@@ -731,7 +737,7 @@ async fn sandbox_blocks_root_read_carveouts_under_bwrap() {
|
||||
file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
LONG_TIMEOUT_MS,
|
||||
false,
|
||||
/*use_legacy_landlock*/ false,
|
||||
)
|
||||
.await,
|
||||
"root-read carveout should be denied under bubblewrap",
|
||||
|
||||
@@ -44,7 +44,7 @@ const PROXY_ENV_KEYS: &[&str] = &[
|
||||
|
||||
fn create_env_from_core_vars() -> HashMap<String, String> {
|
||||
let policy = ShellEnvironmentPolicy::default();
|
||||
create_env(&policy, None)
|
||||
create_env(&policy, /*thread_id*/ None)
|
||||
}
|
||||
|
||||
fn strip_proxy_env(env: &mut HashMap<String, String>) {
|
||||
@@ -66,7 +66,7 @@ async fn should_skip_bwrap_tests() -> bool {
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
false,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -92,7 +92,7 @@ async fn managed_proxy_skip_reason() -> Option<String> {
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -171,7 +171,7 @@ async fn managed_proxy_mode_fails_closed_without_proxy_env() {
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -226,7 +226,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
|
||||
"proxy=\"${HTTP_PROXY#*://}\"; host=\"${proxy%%:*}\"; port=\"${proxy##*:}\"; exec 3<>/dev/tcp/${host}/${port}; printf 'GET http://example.com/ HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n' >&3; IFS= read -r line <&3; printf '%s\\n' \"$line\"",
|
||||
],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
env.clone(),
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -257,7 +257,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
|
||||
let direct_egress_output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
@@ -295,7 +295,7 @@ async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() {
|
||||
"import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n",
|
||||
],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
@@ -119,6 +119,7 @@ pub fn originator() -> Originator {
|
||||
|
||||
pub fn is_first_party_originator(originator_value: &str) -> bool {
|
||||
originator_value == DEFAULT_ORIGINATOR
|
||||
|| originator_value == "codex-tui"
|
||||
|| originator_value == "codex_vscode"
|
||||
|| originator_value.starts_with("Codex ")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ fn test_get_codex_user_agent() {
|
||||
#[test]
|
||||
fn is_first_party_originator_matches_known_values() {
|
||||
assert_eq!(is_first_party_originator(DEFAULT_ORIGINATOR), true);
|
||||
assert_eq!(is_first_party_originator("codex-tui"), true);
|
||||
assert_eq!(is_first_party_originator("codex_vscode"), true);
|
||||
assert_eq!(is_first_party_originator("Codex Something Else"), true);
|
||||
assert_eq!(is_first_party_originator("codex_cli"), false);
|
||||
|
||||
@@ -336,7 +336,6 @@ async fn run_codex_tool_session_inner(
|
||||
| EventMsg::McpToolCallBegin(_)
|
||||
| EventMsg::McpToolCallEnd(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Base namespace for custom prompt slash commands (without trailing colon).
|
||||
/// Example usage forms constructed in code:
|
||||
/// - Command token after '/': `"{PROMPTS_CMD_PREFIX}:name"`
|
||||
/// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"`
|
||||
pub const PROMPTS_CMD_PREFIX: &str = "prompts";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
|
||||
pub struct CustomPrompt {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub content: String,
|
||||
pub description: Option<String>,
|
||||
pub argument_hint: Option<String>,
|
||||
}
|
||||
@@ -5,7 +5,6 @@ pub use agent_path::AgentPath;
|
||||
pub use thread_id::ThreadId;
|
||||
pub mod approvals;
|
||||
pub mod config_types;
|
||||
pub mod custom_prompts;
|
||||
pub mod dynamic_tools;
|
||||
pub mod items;
|
||||
pub mod mcp;
|
||||
|
||||
@@ -23,7 +23,6 @@ use crate::config_types::Personality;
|
||||
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use crate::config_types::ServiceTier;
|
||||
use crate::config_types::WindowsSandboxLevel;
|
||||
use crate::custom_prompts::CustomPrompt;
|
||||
use crate::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use crate::dynamic_tools::DynamicToolCallRequest;
|
||||
use crate::dynamic_tools::DynamicToolResponse;
|
||||
@@ -451,9 +450,6 @@ pub enum Op {
|
||||
/// enable/disable state) without restarting the thread.
|
||||
ReloadUserConfig,
|
||||
|
||||
/// Request the list of available custom prompts.
|
||||
ListCustomPrompts,
|
||||
|
||||
/// Request the list of skills for the provided `cwd` values or the session default.
|
||||
ListSkills {
|
||||
/// Working directories to scope repo skills discovery.
|
||||
@@ -595,7 +591,6 @@ impl Op {
|
||||
Self::ListMcpTools => "list_mcp_tools",
|
||||
Self::RefreshMcpServers { .. } => "refresh_mcp_servers",
|
||||
Self::ReloadUserConfig => "reload_user_config",
|
||||
Self::ListCustomPrompts => "list_custom_prompts",
|
||||
Self::ListSkills { .. } => "list_skills",
|
||||
Self::Compact => "compact",
|
||||
Self::DropMemories => "drop_memories",
|
||||
@@ -1370,9 +1365,6 @@ pub enum EventMsg {
|
||||
/// List of MCP tools available to the agent.
|
||||
McpListToolsResponse(McpListToolsResponseEvent),
|
||||
|
||||
/// List of custom prompts available to the agent.
|
||||
ListCustomPromptsResponse(ListCustomPromptsResponseEvent),
|
||||
|
||||
/// List of skills available to the agent.
|
||||
ListSkillsResponse(ListSkillsResponseEvent),
|
||||
|
||||
@@ -3102,12 +3094,6 @@ impl fmt::Display for McpAuthStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Response payload for `Op::ListCustomPrompts`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ListCustomPromptsResponseEvent {
|
||||
pub custom_prompts: Vec<CustomPrompt>,
|
||||
}
|
||||
|
||||
/// Response payload for `Op::ListSkills`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ListSkillsResponseEvent {
|
||||
|
||||
@@ -161,7 +161,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
|
||||
@@ -8,6 +8,7 @@ version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-code-mode = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
rmcp = { workspace = true, default-features = false, features = [
|
||||
"base64",
|
||||
|
||||
@@ -11,20 +11,29 @@ schema and Responses API tool primitives that no longer need to live in
|
||||
- `JsonSchema`
|
||||
- `AdditionalProperties`
|
||||
- `ToolDefinition`
|
||||
- `ToolSpec`
|
||||
- `ConfiguredToolSpec`
|
||||
- `ResponsesApiTool`
|
||||
- `FreeformTool`
|
||||
- `FreeformToolFormat`
|
||||
- `ToolSearchOutputTool`
|
||||
- `ResponsesApiWebSearchFilters`
|
||||
- `ResponsesApiWebSearchUserLocation`
|
||||
- `ResponsesApiNamespace`
|
||||
- `ResponsesApiNamespaceTool`
|
||||
- code-mode `ToolSpec` adapters
|
||||
- local host tool spec builders for shell/exec/request-permissions/view-image
|
||||
- `parse_tool_input_schema()`
|
||||
- `parse_dynamic_tool()`
|
||||
- `parse_mcp_tool()`
|
||||
- `create_tools_json_for_responses_api()`
|
||||
- `mcp_call_tool_result_output_schema()`
|
||||
- `tool_definition_to_responses_api_tool()`
|
||||
- `dynamic_tool_to_responses_api_tool()`
|
||||
- `mcp_tool_to_responses_api_tool()`
|
||||
- `mcp_tool_to_deferred_responses_api_tool()`
|
||||
- `augment_tool_spec_for_code_mode()`
|
||||
- `tool_spec_to_code_mode_tool_definition()`
|
||||
|
||||
That extraction is the first step in a longer migration. The goal is not to
|
||||
move all of `core/src/tools` into this crate in one shot. Instead, the plan is
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::ToolSpec;
|
||||
use codex_code_mode::CodeModeToolKind;
|
||||
use codex_code_mode::ToolDefinition as CodeModeToolDefinition;
|
||||
|
||||
/// Augment tool descriptions with code-mode-specific exec samples.
|
||||
pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec {
|
||||
let Some(description) = code_mode_tool_definition_for_spec(&spec)
|
||||
.map(codex_code_mode::augment_tool_definition)
|
||||
.map(|definition| definition.description)
|
||||
else {
|
||||
return spec;
|
||||
};
|
||||
|
||||
match spec {
|
||||
ToolSpec::Function(mut tool) => {
|
||||
tool.description = description;
|
||||
ToolSpec::Function(tool)
|
||||
}
|
||||
ToolSpec::Freeform(mut tool) => {
|
||||
tool.description = description;
|
||||
ToolSpec::Freeform(tool)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a supported nested tool spec into the code-mode runtime shape,
|
||||
/// including the code-mode-specific description sample.
|
||||
pub fn tool_spec_to_code_mode_tool_definition(spec: &ToolSpec) -> Option<CodeModeToolDefinition> {
|
||||
let definition = code_mode_tool_definition_for_spec(spec)?;
|
||||
codex_code_mode::is_code_mode_nested_tool(&definition.name)
|
||||
.then(|| codex_code_mode::augment_tool_definition(definition))
|
||||
}
|
||||
|
||||
fn code_mode_tool_definition_for_spec(spec: &ToolSpec) -> Option<CodeModeToolDefinition> {
|
||||
match spec {
|
||||
ToolSpec::Function(tool) => Some(CodeModeToolDefinition {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: serde_json::to_value(&tool.parameters).ok(),
|
||||
output_schema: tool.output_schema.clone(),
|
||||
}),
|
||||
ToolSpec::Freeform(tool) => Some(CodeModeToolDefinition {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
kind: CodeModeToolKind::Freeform,
|
||||
input_schema: None,
|
||||
output_schema: None,
|
||||
}),
|
||||
ToolSpec::LocalShell {}
|
||||
| ToolSpec::ImageGeneration { .. }
|
||||
| ToolSpec::ToolSearch { .. }
|
||||
| ToolSpec::WebSearch { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "code_mode_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::augment_tool_spec_for_code_mode;
|
||||
use super::tool_spec_to_code_mode_tool_definition;
|
||||
use crate::AdditionalProperties;
|
||||
use crate::FreeformTool;
|
||||
use crate::FreeformToolFormat;
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::ToolSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn augment_tool_spec_for_code_mode_augments_function_tools() {
|
||||
assert_eq!(
|
||||
augment_tool_spec_for_code_mode(ToolSpec::Function(ResponsesApiTool {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"order_id".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
)]),
|
||||
required: Some(vec!["order_id".to_string()]),
|
||||
additional_properties: Some(AdditionalProperties::Boolean(false)),
|
||||
},
|
||||
output_schema: Some(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"}
|
||||
},
|
||||
"required": ["ok"],
|
||||
})),
|
||||
})),
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order\n\nexec tool declaration:\n```ts\ndeclare const tools: { lookup_order(args: { order_id: string; }): Promise<{ ok: boolean; }>; };\n```".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"order_id".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
)]),
|
||||
required: Some(vec!["order_id".to_string()]),
|
||||
additional_properties: Some(AdditionalProperties::Boolean(false)),
|
||||
},
|
||||
output_schema: Some(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {"type": "boolean"}
|
||||
},
|
||||
"required": ["ok"],
|
||||
})),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augment_tool_spec_for_code_mode_preserves_exec_tool_description() {
|
||||
assert_eq!(
|
||||
augment_tool_spec_for_code_mode(ToolSpec::Freeform(FreeformTool {
|
||||
name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
|
||||
description: "Run code".to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: "start: \"exec\"".to_string(),
|
||||
},
|
||||
})),
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
|
||||
description: "Run code".to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: "start: \"exec\"".to_string(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_spec_to_code_mode_tool_definition_returns_augmented_nested_tools() {
|
||||
let spec = ToolSpec::Freeform(FreeformTool {
|
||||
name: "apply_patch".to_string(),
|
||||
description: "Apply a patch".to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: "start: \"patch\"".to_string(),
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
tool_spec_to_code_mode_tool_definition(&spec),
|
||||
Some(codex_code_mode::ToolDefinition {
|
||||
name: "apply_patch".to_string(),
|
||||
description: "Apply a patch\n\nexec tool declaration:\n```ts\ndeclare const tools: { apply_patch(input: string): Promise<unknown>; };\n```".to_string(),
|
||||
kind: codex_code_mode::CodeModeToolKind::Freeform,
|
||||
input_schema: None,
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_spec_to_code_mode_tool_definition_skips_unsupported_variants() {
|
||||
assert_eq!(
|
||||
tool_spec_to_code_mode_tool_definition(&ToolSpec::ToolSearch {
|
||||
execution: "sync".to_string(),
|
||||
description: "Search".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
//! Shared tool definitions and Responses API tool primitives that can live
|
||||
//! outside `codex-core`.
|
||||
|
||||
mod code_mode;
|
||||
mod dynamic_tool;
|
||||
mod json_schema;
|
||||
mod local_tool;
|
||||
mod mcp_tool;
|
||||
mod responses_api;
|
||||
mod tool_definition;
|
||||
mod tool_spec;
|
||||
mod view_image;
|
||||
|
||||
pub use code_mode::augment_tool_spec_for_code_mode;
|
||||
pub use code_mode::tool_spec_to_code_mode_tool_definition;
|
||||
pub use dynamic_tool::parse_dynamic_tool;
|
||||
pub use json_schema::AdditionalProperties;
|
||||
pub use json_schema::JsonSchema;
|
||||
pub use json_schema::parse_tool_input_schema;
|
||||
pub use local_tool::CommandToolOptions;
|
||||
pub use local_tool::ShellToolOptions;
|
||||
pub use local_tool::create_exec_command_tool;
|
||||
pub use local_tool::create_request_permissions_tool;
|
||||
pub use local_tool::create_shell_command_tool;
|
||||
pub use local_tool::create_shell_tool;
|
||||
pub use local_tool::create_write_stdin_tool;
|
||||
pub use mcp_tool::mcp_call_tool_result_output_schema;
|
||||
pub use mcp_tool::parse_mcp_tool;
|
||||
pub use responses_api::FreeformTool;
|
||||
@@ -24,3 +37,10 @@ pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
|
||||
pub use responses_api::mcp_tool_to_responses_api_tool;
|
||||
pub use responses_api::tool_definition_to_responses_api_tool;
|
||||
pub use tool_definition::ToolDefinition;
|
||||
pub use tool_spec::ConfiguredToolSpec;
|
||||
pub use tool_spec::ResponsesApiWebSearchFilters;
|
||||
pub use tool_spec::ResponsesApiWebSearchUserLocation;
|
||||
pub use tool_spec::ToolSpec;
|
||||
pub use tool_spec::create_tools_json_for_responses_api;
|
||||
pub use view_image::ViewImageToolOptions;
|
||||
pub use view_image::create_view_image_tool;
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::ToolSpec;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CommandToolOptions {
|
||||
pub allow_login_shell: bool,
|
||||
pub exec_permission_approvals_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShellToolOptions {
|
||||
pub exec_permission_approvals_enabled: bool,
|
||||
}
|
||||
|
||||
pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"cmd".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Shell command to execute.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional working directory to run the command in; defaults to the turn cwd."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"shell".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"tty".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
if options.allow_login_shell {
|
||||
properties.insert(
|
||||
"login".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
properties.extend(create_approval_parameters(
|
||||
options.exec_permission_approvals_enabled,
|
||||
));
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "exec_command".to_string(),
|
||||
description: if cfg!(windows) {
|
||||
format!(
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n\n{}",
|
||||
windows_destructive_filesystem_guidance()
|
||||
)
|
||||
} else {
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
|
||||
.to_string()
|
||||
},
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["cmd".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(unified_exec_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_write_stdin_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"session_id".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Identifier of the running unified exec session.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"chars".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "write_stdin".to_string(),
|
||||
description:
|
||||
"Writes characters to an existing unified exec session and returns recent output."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["session_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(unified_exec_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_shell_tool(options: ShellToolOptions) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("The command to execute".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
properties.extend(create_approval_parameters(
|
||||
options.exec_permission_approvals_enabled,
|
||||
));
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
format!(
|
||||
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
|
||||
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
|
||||
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
|
||||
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"]
|
||||
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
|
||||
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]
|
||||
|
||||
{}"#,
|
||||
windows_destructive_filesystem_guidance()
|
||||
)
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
|
||||
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
|
||||
.to_string()
|
||||
};
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"The shell script to execute in the user's default shell".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
if options.allow_login_shell {
|
||||
properties.insert(
|
||||
"login".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to run the shell with login shell semantics. Defaults to true."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
properties.extend(create_approval_parameters(
|
||||
options.exec_permission_approvals_enabled,
|
||||
));
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
format!(
|
||||
r#"Runs a Powershell command (Windows) and returns its output.
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): "Get-ChildItem -Force"
|
||||
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
|
||||
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
|
||||
- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"
|
||||
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
|
||||
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"
|
||||
|
||||
{}"#,
|
||||
windows_destructive_filesystem_guidance()
|
||||
)
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
|
||||
.to_string()
|
||||
};
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell_command".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_request_permissions_tool(description: String) -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"reason".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional short explanation for why additional permissions are needed."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
("permissions".to_string(), permission_profile_schema()),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "request_permissions".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["permissions".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn unified_exec_output_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chunk_id": {
|
||||
"type": "string",
|
||||
"description": "Chunk identifier included when the response reports one."
|
||||
},
|
||||
"wall_time_seconds": {
|
||||
"type": "number",
|
||||
"description": "Elapsed wall time spent waiting for output in seconds."
|
||||
},
|
||||
"exit_code": {
|
||||
"type": "number",
|
||||
"description": "Process exit code when the command finished during this call."
|
||||
},
|
||||
"session_id": {
|
||||
"type": "number",
|
||||
"description": "Session identifier to pass to write_stdin when the process is still running."
|
||||
},
|
||||
"original_token_count": {
|
||||
"type": "number",
|
||||
"description": "Approximate token count before output truncation."
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "Command output text, possibly truncated."
|
||||
}
|
||||
},
|
||||
"required": ["wall_time_seconds", "output"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn create_approval_parameters(
|
||||
exec_permission_approvals_enabled: bool,
|
||||
) -> BTreeMap<String, JsonSchema> {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"sandbox_permissions".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
if exec_permission_approvals_enabled {
|
||||
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
} else {
|
||||
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
r#"Only set if sandbox_permissions is \"require_escalated\".
|
||||
Request approval from the user to run this command outside the sandbox.
|
||||
Phrased as a simple question that summarizes the purpose of the
|
||||
command as it relates to the task at hand - e.g. 'Do you want to
|
||||
fetch and pull the latest version of this git branch?'"#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"prefix_rule".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
r#"Only specify when sandbox_permissions is `require_escalated`.
|
||||
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
|
||||
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
if exec_permission_approvals_enabled {
|
||||
properties.insert(
|
||||
"additional_permissions".to_string(),
|
||||
permission_profile_schema(),
|
||||
);
|
||||
}
|
||||
|
||||
properties
|
||||
}
|
||||
|
||||
fn permission_profile_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
("network".to_string(), network_permissions_schema()),
|
||||
("file_system".to_string(), file_system_permissions_schema()),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn network_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"enabled".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Set to true to request network access.".to_string()),
|
||||
},
|
||||
)]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_system_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"read".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("Absolute paths to grant read access to.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"write".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("Absolute paths to grant write access to.".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_destructive_filesystem_guidance() -> &'static str {
|
||||
r#"Windows safety rules:
|
||||
- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.
|
||||
- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked."#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "local_tool_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,442 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn windows_shell_safety_description() -> String {
|
||||
format!("\n\n{}", windows_destructive_filesystem_guidance())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tool_matches_expected_spec() {
|
||||
let tool = create_shell_tool(ShellToolOptions {
|
||||
exec_permission_approvals_enabled: false,
|
||||
});
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
|
||||
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
|
||||
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
|
||||
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
|
||||
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
|
||||
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"#
|
||||
.to_string()
|
||||
+ &windows_shell_safety_description()
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
|
||||
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("The command to execute".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"sandbox_permissions".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
r#"Only set if sandbox_permissions is \"require_escalated\".
|
||||
Request approval from the user to run this command outside the sandbox.
|
||||
Phrased as a simple question that summarizes the purpose of the
|
||||
command as it relates to the task at hand - e.g. 'Do you want to
|
||||
fetch and pull the latest version of this git branch?'"#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"prefix_rule".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
r#"Only specify when sandbox_permissions is `require_escalated`.
|
||||
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
|
||||
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_tool_matches_expected_spec() {
|
||||
let tool = create_exec_command_tool(CommandToolOptions {
|
||||
allow_login_shell: true,
|
||||
exec_permission_approvals_enabled: false,
|
||||
});
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
format!(
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.{}",
|
||||
windows_shell_safety_description()
|
||||
)
|
||||
} else {
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"cmd".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Shell command to execute.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional working directory to run the command in; defaults to the turn cwd."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"shell".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"tty".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"login".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
properties.extend(create_approval_parameters(
|
||||
/*exec_permission_approvals_enabled*/ false,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "exec_command".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["cmd".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(unified_exec_output_schema()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_stdin_tool_matches_expected_spec() {
|
||||
let tool = create_write_stdin_tool();
|
||||
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"session_id".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Identifier of the running unified exec session.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"chars".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "write_stdin".to_string(),
|
||||
description:
|
||||
"Writes characters to an existing unified exec session and returns recent output."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["session_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(unified_exec_output_schema()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tool_with_request_permission_includes_additional_permissions() {
|
||||
let tool = create_shell_tool(ShellToolOptions {
|
||||
exec_permission_approvals_enabled: true,
|
||||
});
|
||||
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("The command to execute".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
properties.extend(create_approval_parameters(
|
||||
/*exec_permission_approvals_enabled*/ true,
|
||||
));
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
format!(
|
||||
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
|
||||
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
|
||||
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
|
||||
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"]
|
||||
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
|
||||
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]
|
||||
|
||||
{}"#,
|
||||
windows_destructive_filesystem_guidance()
|
||||
)
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
|
||||
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
|
||||
.to_string()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_permissions_tool_includes_full_permission_schema() {
|
||||
let tool =
|
||||
create_request_permissions_tool("Request extra permissions for this turn.".to_string());
|
||||
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"reason".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional short explanation for why additional permissions are needed."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
("permissions".to_string(), permission_profile_schema()),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "request_permissions".to_string(),
|
||||
description: "Request extra permissions for this turn.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["permissions".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_tool_matches_expected_spec() {
|
||||
let tool = create_shell_command_tool(CommandToolOptions {
|
||||
allow_login_shell: true,
|
||||
exec_permission_approvals_enabled: false,
|
||||
});
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
r#"Runs a Powershell command (Windows) and returns its output.
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): "Get-ChildItem -Force"
|
||||
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
|
||||
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
|
||||
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
|
||||
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
|
||||
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""#
|
||||
.to_string()
|
||||
+ &windows_shell_safety_description()
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"The shell script to execute in the user's default shell".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"login".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to run the shell with login shell semantics. Defaults to true."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
properties.extend(create_approval_parameters(
|
||||
/*exec_permission_approvals_enabled*/ false,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell_command".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use crate::FreeformTool;
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use codex_protocol::config_types::WebSearchContextSize;
|
||||
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
|
||||
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
|
||||
use codex_protocol::config_types::WebSearchUserLocationType;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
|
||||
/// Responses API.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ToolSpec {
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
#[serde(rename = "tool_search")]
|
||||
ToolSearch {
|
||||
execution: String,
|
||||
description: String,
|
||||
parameters: JsonSchema,
|
||||
},
|
||||
#[serde(rename = "local_shell")]
|
||||
LocalShell {},
|
||||
#[serde(rename = "image_generation")]
|
||||
ImageGeneration { output_format: String },
|
||||
// TODO: Understand why we get an error on web_search although the API docs
|
||||
// say it's supported.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
|
||||
// The `external_web_access` field determines whether the web search is over
|
||||
// cached or live content.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search#live-internet-access
|
||||
#[serde(rename = "web_search")]
|
||||
WebSearch {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_web_access: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
filters: Option<ResponsesApiWebSearchFilters>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_location: Option<ResponsesApiWebSearchUserLocation>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_context_size: Option<WebSearchContextSize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_content_types: Option<Vec<String>>,
|
||||
},
|
||||
#[serde(rename = "custom")]
|
||||
Freeform(FreeformTool),
|
||||
}
|
||||
|
||||
impl ToolSpec {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
ToolSpec::Function(tool) => tool.name.as_str(),
|
||||
ToolSpec::ToolSearch { .. } => "tool_search",
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration { .. } => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
ToolSpec::Freeform(tool) => tool.name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ConfiguredToolSpec {
|
||||
pub spec: ToolSpec,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
impl ConfiguredToolSpec {
|
||||
pub fn new(spec: ToolSpec, supports_parallel_tool_calls: bool) -> Self {
|
||||
Self {
|
||||
spec,
|
||||
supports_parallel_tool_calls,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.spec.name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns JSON values that are compatible with Function Calling in the
|
||||
/// Responses API:
|
||||
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
|
||||
pub fn create_tools_json_for_responses_api(
|
||||
tools: &[ToolSpec],
|
||||
) -> Result<Vec<Value>, serde_json::Error> {
|
||||
let mut tools_json = Vec::new();
|
||||
|
||||
for tool in tools {
|
||||
let json = serde_json::to_value(tool)?;
|
||||
tools_json.push(json);
|
||||
}
|
||||
|
||||
Ok(tools_json)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiWebSearchFilters {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<ConfigWebSearchFilters> for ResponsesApiWebSearchFilters {
|
||||
fn from(filters: ConfigWebSearchFilters) -> Self {
|
||||
Self {
|
||||
allowed_domains: filters.allowed_domains,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiWebSearchUserLocation {
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: WebSearchUserLocationType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub country: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub city: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigWebSearchUserLocation> for ResponsesApiWebSearchUserLocation {
|
||||
fn from(user_location: ConfigWebSearchUserLocation) -> Self {
|
||||
Self {
|
||||
r#type: user_location.r#type,
|
||||
country: user_location.country,
|
||||
region: user_location.region,
|
||||
city: user_location.city,
|
||||
timezone: user_location.timezone,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tool_spec_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,242 @@
|
||||
use super::ConfiguredToolSpec;
|
||||
use super::ResponsesApiWebSearchFilters;
|
||||
use super::ResponsesApiWebSearchUserLocation;
|
||||
use super::ToolSpec;
|
||||
use crate::AdditionalProperties;
|
||||
use crate::FreeformTool;
|
||||
use crate::FreeformToolFormat;
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::create_tools_json_for_responses_api;
|
||||
use codex_protocol::config_types::WebSearchContextSize;
|
||||
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
|
||||
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
|
||||
use codex_protocol::config_types::WebSearchUserLocationType;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn tool_spec_name_covers_all_variants() {
|
||||
assert_eq!(
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
.name(),
|
||||
"lookup_order"
|
||||
);
|
||||
assert_eq!(
|
||||
ToolSpec::ToolSearch {
|
||||
execution: "sync".to_string(),
|
||||
description: "Search for tools".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
}
|
||||
.name(),
|
||||
"tool_search"
|
||||
);
|
||||
assert_eq!(ToolSpec::LocalShell {}.name(), "local_shell");
|
||||
assert_eq!(
|
||||
ToolSpec::ImageGeneration {
|
||||
output_format: "png".to_string(),
|
||||
}
|
||||
.name(),
|
||||
"image_generation"
|
||||
);
|
||||
assert_eq!(
|
||||
ToolSpec::WebSearch {
|
||||
external_web_access: Some(true),
|
||||
filters: None,
|
||||
user_location: None,
|
||||
search_context_size: None,
|
||||
search_content_types: None,
|
||||
}
|
||||
.name(),
|
||||
"web_search"
|
||||
);
|
||||
assert_eq!(
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: "exec".to_string(),
|
||||
description: "Run a command".to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: "start: \"exec\"".to_string(),
|
||||
},
|
||||
})
|
||||
.name(),
|
||||
"exec"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_tool_spec_name_delegates_to_tool_spec() {
|
||||
assert_eq!(
|
||||
ConfiguredToolSpec::new(
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
}),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
)
|
||||
.name(),
|
||||
"lookup_order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_config_converts_to_responses_api_types() {
|
||||
assert_eq!(
|
||||
ResponsesApiWebSearchFilters::from(ConfigWebSearchFilters {
|
||||
allowed_domains: Some(vec!["example.com".to_string()]),
|
||||
}),
|
||||
ResponsesApiWebSearchFilters {
|
||||
allowed_domains: Some(vec!["example.com".to_string()]),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ResponsesApiWebSearchUserLocation::from(ConfigWebSearchUserLocation {
|
||||
r#type: WebSearchUserLocationType::Approximate,
|
||||
country: Some("US".to_string()),
|
||||
region: Some("California".to_string()),
|
||||
city: Some("San Francisco".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
}),
|
||||
ResponsesApiWebSearchUserLocation {
|
||||
r#type: WebSearchUserLocationType::Approximate,
|
||||
country: Some("US".to_string()),
|
||||
region: Some("California".to_string()),
|
||||
city: Some("San Francisco".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tools_json_for_responses_api_includes_top_level_name() {
|
||||
assert_eq!(
|
||||
create_tools_json_for_responses_api(&[ToolSpec::Function(ResponsesApiTool {
|
||||
name: "demo".to_string(),
|
||||
description: "A demo tool".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"foo".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
)]),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
})])
|
||||
.expect("serialize tools"),
|
||||
vec![json!({
|
||||
"type": "function",
|
||||
"name": "demo",
|
||||
"description": "A demo tool",
|
||||
"strict": false,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foo": { "type": "string" }
|
||||
},
|
||||
},
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_tool_spec_serializes_expected_wire_shape() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(ToolSpec::WebSearch {
|
||||
external_web_access: Some(true),
|
||||
filters: Some(ResponsesApiWebSearchFilters {
|
||||
allowed_domains: Some(vec!["example.com".to_string()]),
|
||||
}),
|
||||
user_location: Some(ResponsesApiWebSearchUserLocation {
|
||||
r#type: WebSearchUserLocationType::Approximate,
|
||||
country: Some("US".to_string()),
|
||||
region: Some("California".to_string()),
|
||||
city: Some("San Francisco".to_string()),
|
||||
timezone: Some("America/Los_Angeles".to_string()),
|
||||
}),
|
||||
search_context_size: Some(WebSearchContextSize::High),
|
||||
search_content_types: Some(vec!["text".to_string(), "image".to_string()]),
|
||||
})
|
||||
.expect("serialize web_search"),
|
||||
json!({
|
||||
"type": "web_search",
|
||||
"external_web_access": true,
|
||||
"filters": {
|
||||
"allowed_domains": ["example.com"],
|
||||
},
|
||||
"user_location": {
|
||||
"type": "approximate",
|
||||
"country": "US",
|
||||
"region": "California",
|
||||
"city": "San Francisco",
|
||||
"timezone": "America/Los_Angeles",
|
||||
},
|
||||
"search_context_size": "high",
|
||||
"search_content_types": ["text", "image"],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_tool_spec_serializes_expected_wire_shape() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(ToolSpec::ToolSearch {
|
||||
execution: "sync".to_string(),
|
||||
description: "Search app tools".to_string(),
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"query".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Tool search query".to_string()),
|
||||
},
|
||||
)]),
|
||||
required: Some(vec!["query".to_string()]),
|
||||
additional_properties: Some(AdditionalProperties::Boolean(false)),
|
||||
},
|
||||
})
|
||||
.expect("serialize tool_search"),
|
||||
json!({
|
||||
"type": "tool_search",
|
||||
"execution": "sync",
|
||||
"description": "Search app tools",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Tool search query",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::ToolSpec;
|
||||
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ViewImageToolOptions {
|
||||
pub can_request_original_image_detail: bool,
|
||||
}
|
||||
|
||||
pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([(
|
||||
"path".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Local filesystem path to an image file".to_string()),
|
||||
},
|
||||
)]);
|
||||
if options.can_request_original_image_detail {
|
||||
properties.insert(
|
||||
"detail".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: VIEW_IMAGE_TOOL_NAME.to_string(),
|
||||
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 <image ...> tags)."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(view_image_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
fn view_image_output_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_url": {
|
||||
"type": "string",
|
||||
"description": "Data URL for the loaded image."
|
||||
},
|
||||
"detail": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`."
|
||||
}
|
||||
},
|
||||
"required": ["image_url", "detail"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "view_image_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,67 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn view_image_tool_omits_detail_without_original_detail_feature() {
|
||||
assert_eq!(
|
||||
create_view_image_tool(ViewImageToolOptions {
|
||||
can_request_original_image_detail: false,
|
||||
}),
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "view_image".to_string(),
|
||||
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 <image ...> tags)."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"path".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Local filesystem path to an image file".to_string()),
|
||||
},
|
||||
)]),
|
||||
required: Some(vec!["path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(view_image_output_schema()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_image_tool_includes_detail_with_original_detail_feature() {
|
||||
assert_eq!(
|
||||
create_view_image_tool(ViewImageToolOptions {
|
||||
can_request_original_image_detail: true,
|
||||
}),
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "view_image".to_string(),
|
||||
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 <image ...> tags)."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"detail".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"path".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Local filesystem path to an image file".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: Some(vec!["path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(view_image_output_schema()),
|
||||
})
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,27 +10,21 @@ use super::slash_commands;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Hide alias commands in the default popup list so each unique action appears once.
|
||||
// `quit` is an alias of `exit`, so we skip `quit` here.
|
||||
// `approvals` is an alias of `permissions`.
|
||||
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
/// A selectable item in the popup.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CommandItem {
|
||||
Builtin(SlashCommand),
|
||||
// Index into `prompts`
|
||||
UserPrompt(usize),
|
||||
}
|
||||
|
||||
pub(crate) struct CommandPopup {
|
||||
command_filter: String,
|
||||
builtins: Vec<(&'static str, SlashCommand)>,
|
||||
prompts: Vec<CustomPrompt>,
|
||||
state: ScrollState,
|
||||
}
|
||||
|
||||
@@ -62,7 +56,7 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
|
||||
pub(crate) fn new(flags: CommandPopupFlags) -> Self {
|
||||
// Keep built-in availability in sync with the composer.
|
||||
let builtins: Vec<(&'static str, SlashCommand)> =
|
||||
slash_commands::builtins_for_input(flags.into())
|
||||
@@ -70,34 +64,13 @@ impl CommandPopup {
|
||||
.filter(|(name, _)| !name.starts_with("debug"))
|
||||
.filter(|(_, cmd)| *cmd != SlashCommand::Apps)
|
||||
.collect();
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||
prompts.retain(|p| !exclude.contains(&p.name));
|
||||
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self {
|
||||
command_filter: String::new(),
|
||||
builtins,
|
||||
prompts,
|
||||
state: ScrollState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
|
||||
let exclude: HashSet<String> = self
|
||||
.builtins
|
||||
.iter()
|
||||
.map(|(n, _)| (*n).to_string())
|
||||
.collect();
|
||||
prompts.retain(|p| !exclude.contains(&p.name));
|
||||
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
self.prompts = prompts;
|
||||
}
|
||||
|
||||
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
|
||||
self.prompts.get(idx)
|
||||
}
|
||||
|
||||
/// Update the filter string based on the current composer text. The text
|
||||
/// passed in is expected to start with a leading '/'. Everything after the
|
||||
/// *first* '/' on the *first* line becomes the active filter that is used
|
||||
@@ -145,17 +118,12 @@ impl CommandPopup {
|
||||
let filter = self.command_filter.trim();
|
||||
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
|
||||
if filter.is_empty() {
|
||||
// Built-ins first, in presentation order.
|
||||
for (_, cmd) in self.builtins.iter() {
|
||||
if ALIAS_COMMANDS.contains(cmd) {
|
||||
continue;
|
||||
}
|
||||
out.push((CommandItem::Builtin(*cmd), None));
|
||||
}
|
||||
// Then prompts, already sorted by name.
|
||||
for idx in 0..self.prompts.len() {
|
||||
out.push((CommandItem::UserPrompt(idx), None));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -163,7 +131,6 @@ impl CommandPopup {
|
||||
let filter_chars = filter.chars().count();
|
||||
let mut exact: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
|
||||
let mut prefix: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
|
||||
let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1;
|
||||
let indices_for = |offset| Some((offset..offset + filter_chars).collect());
|
||||
|
||||
let mut push_match =
|
||||
@@ -190,18 +157,6 @@ impl CommandPopup {
|
||||
for (_, cmd) in self.builtins.iter() {
|
||||
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
|
||||
}
|
||||
// Support both search styles:
|
||||
// - Typing "name" should surface "/prompts:name" results.
|
||||
// - Typing "prompts:name" should also work.
|
||||
for (idx, p) in self.prompts.iter().enumerate() {
|
||||
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
|
||||
push_match(
|
||||
CommandItem::UserPrompt(idx),
|
||||
&display,
|
||||
Some(&p.name),
|
||||
prompt_prefix_len,
|
||||
);
|
||||
}
|
||||
|
||||
out.extend(exact);
|
||||
out.extend(prefix);
|
||||
@@ -219,22 +174,9 @@ impl CommandPopup {
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|(item, indices)| {
|
||||
let (name, description) = match item {
|
||||
CommandItem::Builtin(cmd) => {
|
||||
(format!("/{}", cmd.command()), cmd.description().to_string())
|
||||
}
|
||||
CommandItem::UserPrompt(i) => {
|
||||
let prompt = &self.prompts[i];
|
||||
let description = prompt
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "send saved prompt".to_string());
|
||||
(
|
||||
format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name),
|
||||
description,
|
||||
)
|
||||
}
|
||||
};
|
||||
let CommandItem::Builtin(cmd) = item;
|
||||
let name = format!("/{}", cmd.command());
|
||||
let description = cmd.description().to_string();
|
||||
GenericDisplayRow {
|
||||
name,
|
||||
name_prefix_spans: Vec::new(),
|
||||
@@ -297,7 +239,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn filter_includes_init_when_typing_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
// Simulate the composer line starting with '/in' so the popup filters
|
||||
// matching commands by prefix.
|
||||
popup.on_composer_text_change("/in".to_string());
|
||||
@@ -307,7 +249,6 @@ mod tests {
|
||||
let matches = popup.filtered_items();
|
||||
let has_init = matches.iter().any(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command() == "init",
|
||||
CommandItem::UserPrompt(_) => false,
|
||||
});
|
||||
assert!(
|
||||
has_init,
|
||||
@@ -317,7 +258,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selecting_init_by_exact_match() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/init".to_string());
|
||||
|
||||
// When an exact match exists, the selected command should be that
|
||||
@@ -325,144 +266,46 @@ mod tests {
|
||||
let selected = popup.selected_item();
|
||||
match selected {
|
||||
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
|
||||
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
|
||||
None => panic!("expected a selected command for exact match"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_first_suggestion_for_mo() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/mo".to_string());
|
||||
let matches = popup.filtered_items();
|
||||
match matches.first() {
|
||||
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
|
||||
Some(CommandItem::UserPrompt(_)) => {
|
||||
panic!("unexpected prompt ranked before '/model' for '/mo'")
|
||||
}
|
||||
None => panic!("expected at least one match for '/mo'"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_commands_keep_presentation_order_for_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/m".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_discovery_lists_custom_prompts() {
|
||||
let prompts = vec![
|
||||
CustomPrompt {
|
||||
name: "foo".to_string(),
|
||||
path: "/tmp/foo.md".to_string().into(),
|
||||
content: "hello from foo".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
},
|
||||
CustomPrompt {
|
||||
name: "bar".to_string(),
|
||||
path: "/tmp/bar.md".to_string().into(),
|
||||
content: "hello from bar".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
},
|
||||
];
|
||||
let popup = CommandPopup::new(prompts, CommandPopupFlags::default());
|
||||
let items = popup.filtered_items();
|
||||
let mut prompt_names: Vec<String> = items
|
||||
.into_iter()
|
||||
.filter_map(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
prompt_names.sort();
|
||||
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_name_collision_with_builtin_is_ignored() {
|
||||
// Create a prompt named like a builtin (e.g. "init").
|
||||
let popup = CommandPopup::new(
|
||||
vec![CustomPrompt {
|
||||
name: "init".to_string(),
|
||||
path: "/tmp/init.md".to_string().into(),
|
||||
content: "should be ignored".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}],
|
||||
CommandPopupFlags::default(),
|
||||
);
|
||||
let items = popup.filtered_items();
|
||||
let has_collision_prompt = items.into_iter().any(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
!has_collision_prompt,
|
||||
"prompt with builtin name should be ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_description_uses_frontmatter_metadata() {
|
||||
let popup = CommandPopup::new(
|
||||
vec![CustomPrompt {
|
||||
name: "draftpr".to_string(),
|
||||
path: "/tmp/draftpr.md".to_string().into(),
|
||||
content: "body".to_string(),
|
||||
description: Some("Create feature branch, commit and open draft PR.".to_string()),
|
||||
argument_hint: None,
|
||||
}],
|
||||
CommandPopupFlags::default(),
|
||||
);
|
||||
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
|
||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||
assert_eq!(
|
||||
description,
|
||||
Some("Create feature branch, commit and open draft PR.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_description_falls_back_when_missing() {
|
||||
let popup = CommandPopup::new(
|
||||
vec![CustomPrompt {
|
||||
name: "foo".to_string(),
|
||||
path: "/tmp/foo.md".to_string().into(),
|
||||
content: "body".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}],
|
||||
CommandPopupFlags::default(),
|
||||
);
|
||||
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
|
||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||
assert_eq!(description, Some("send saved prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_filter_limits_matches_for_ac() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/ac".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -473,7 +316,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn quit_hidden_in_empty_filter_but_shown_for_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
let items = popup.filtered_items();
|
||||
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
|
||||
@@ -485,15 +328,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn collab_command_hidden_when_collaboration_modes_disabled() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -508,19 +350,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn collab_command_visible_when_collaboration_modes_enabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/collab".to_string());
|
||||
|
||||
match popup.selected_item() {
|
||||
@@ -531,19 +370,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn plan_command_visible_when_collaboration_modes_enabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/plan".to_string());
|
||||
|
||||
match popup.selected_item() {
|
||||
@@ -554,27 +390,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn personality_command_hidden_when_disabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: false,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: false,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/pers".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -585,19 +417,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn personality_command_visible_when_enabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/personality".to_string());
|
||||
|
||||
match popup.selected_item() {
|
||||
@@ -608,27 +437,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: false,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: true,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
},
|
||||
);
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags {
|
||||
collaboration_modes_enabled: false,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: true,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
});
|
||||
popup.on_composer_text_change("/aud".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -640,13 +465,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn debug_commands_are_hidden_from_popup() {
|
||||
let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
let popup = CommandPopup::new(CommandPopupFlags::default());
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -1,63 +1,3 @@
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use lazy_static::lazy_static;
|
||||
use regex_lite::Regex;
|
||||
use shlex::Shlex;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
lazy_static! {
|
||||
static ref PROMPT_ARG_REGEX: Regex =
|
||||
Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort());
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptArgsError {
|
||||
MissingAssignment { token: String },
|
||||
MissingKey { token: String },
|
||||
}
|
||||
|
||||
impl PromptArgsError {
|
||||
fn describe(&self, command: &str) -> String {
|
||||
match self {
|
||||
PromptArgsError::MissingAssignment { token } => format!(
|
||||
"Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces."
|
||||
),
|
||||
PromptArgsError::MissingKey { token } => {
|
||||
format!("Could not parse {command}: expected a name before '=' in '{token}'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptExpansionError {
|
||||
Args {
|
||||
command: String,
|
||||
error: PromptArgsError,
|
||||
},
|
||||
MissingArgs {
|
||||
command: String,
|
||||
missing: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PromptExpansionError {
|
||||
pub fn user_message(&self) -> String {
|
||||
match self {
|
||||
PromptExpansionError::Args { command, error } => error.describe(command),
|
||||
PromptExpansionError::MissingArgs { command, missing } => {
|
||||
let list = missing.join(", ");
|
||||
format!(
|
||||
"Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a first-line slash command of the form `/name <rest>`.
|
||||
/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/`
|
||||
/// and contains a non-empty name; otherwise returns `None`.
|
||||
@@ -80,775 +20,6 @@ pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> {
|
||||
let rest_untrimmed = &stripped[name_end_in_stripped..];
|
||||
let rest = rest_untrimmed.trim_start();
|
||||
let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len());
|
||||
// `stripped` is `line` without the leading '/', so add 1 to get the original offset.
|
||||
let rest_offset = rest_start_in_stripped + 1;
|
||||
Some((name, rest, rest_offset))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PromptArg {
|
||||
pub text: String,
|
||||
pub text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PromptExpansion {
|
||||
pub text: String,
|
||||
pub text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
/// Parse positional arguments using shlex semantics (supports quoted tokens).
|
||||
///
|
||||
/// `text_elements` must be relative to `rest`.
|
||||
pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
|
||||
parse_tokens_with_elements(rest, text_elements)
|
||||
}
|
||||
|
||||
/// Extracts the unique placeholder variable names from a prompt template.
|
||||
///
|
||||
/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*`
|
||||
/// (for example `$USER`). The function returns the variable names without
|
||||
/// the leading `$`, de-duplicated and in the order of first appearance.
|
||||
pub fn prompt_argument_names(content: &str) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut names = Vec::new();
|
||||
for m in PROMPT_ARG_REGEX.find_iter(content) {
|
||||
if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' {
|
||||
continue;
|
||||
}
|
||||
let name = &content[m.start() + 1..m.end()];
|
||||
// Exclude special positional aggregate token from named args.
|
||||
if name == "ARGUMENTS" {
|
||||
continue;
|
||||
}
|
||||
let name = name.to_string();
|
||||
if seen.insert(name.clone()) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Shift a text element's byte range left by `offset`, returning `None` if empty.
|
||||
///
|
||||
/// `offset` is the byte length of the prefix removed from the original text.
|
||||
fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option<TextElement> {
|
||||
if elem.byte_range.end <= offset {
|
||||
return None;
|
||||
}
|
||||
let start = elem.byte_range.start.saturating_sub(offset);
|
||||
let end = elem.byte_range.end.saturating_sub(offset);
|
||||
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
|
||||
}
|
||||
|
||||
/// Parses the `key=value` pairs that follow a custom prompt name.
|
||||
///
|
||||
/// The input is split using shlex rules, so quoted values are supported
|
||||
/// (for example `USER="Alice Smith"`). The function returns a map of parsed
|
||||
/// arguments, or an error if a token is missing `=` or if the key is empty.
|
||||
pub fn parse_prompt_inputs(
|
||||
rest: &str,
|
||||
text_elements: &[TextElement],
|
||||
) -> Result<HashMap<String, PromptArg>, PromptArgsError> {
|
||||
let mut map = HashMap::new();
|
||||
if rest.trim().is_empty() {
|
||||
return Ok(map);
|
||||
}
|
||||
|
||||
// Tokenize the rest of the command using shlex rules, but keep text element
|
||||
// ranges relative to each emitted token.
|
||||
for token in parse_tokens_with_elements(rest, text_elements) {
|
||||
let Some((key, value)) = token.text.split_once('=') else {
|
||||
return Err(PromptArgsError::MissingAssignment { token: token.text });
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(PromptArgsError::MissingKey { token: token.text });
|
||||
}
|
||||
// The token is `key=value`; translate element ranges into the value-only
|
||||
// coordinate space by subtracting the `key=` prefix length.
|
||||
let value_start = key.len() + 1;
|
||||
let value_elements = token
|
||||
.text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| shift_text_element_left(elem, value_start))
|
||||
.collect();
|
||||
map.insert(
|
||||
key.to_string(),
|
||||
PromptArg {
|
||||
text: value.to_string(),
|
||||
text_elements: value_elements,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
|
||||
///
|
||||
/// If the text does not start with `/prompts:`, or if no prompt named `name` exists,
|
||||
/// the function returns `Ok(None)`. On success it returns
|
||||
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
|
||||
pub fn expand_custom_prompt(
|
||||
text: &str,
|
||||
text_elements: &[TextElement],
|
||||
custom_prompts: &[CustomPrompt],
|
||||
) -> Result<Option<PromptExpansion>, PromptExpansionError> {
|
||||
let Some((name, rest, rest_offset)) = parse_slash_name(text) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Only handle custom prompts when using the explicit prompts prefix with a colon.
|
||||
let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) {
|
||||
Some(prompt) => prompt,
|
||||
None => return Ok(None),
|
||||
};
|
||||
// If there are named placeholders, expect key=value inputs.
|
||||
let required = prompt_argument_names(&prompt.content);
|
||||
let local_elements: Vec<TextElement> = text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
let mut shifted = shift_text_element_left(elem, rest_offset)?;
|
||||
if shifted.byte_range.start >= rest.len() {
|
||||
return None;
|
||||
}
|
||||
let end = shifted.byte_range.end.min(rest.len());
|
||||
shifted.byte_range.end = end;
|
||||
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
|
||||
})
|
||||
.collect();
|
||||
if !required.is_empty() {
|
||||
let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| {
|
||||
PromptExpansionError::Args {
|
||||
command: format!("/{name}"),
|
||||
error,
|
||||
}
|
||||
})?;
|
||||
let missing: Vec<String> = required
|
||||
.into_iter()
|
||||
.filter(|k| !inputs.contains_key(k))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(PromptExpansionError::MissingArgs {
|
||||
command: format!("/{name}"),
|
||||
missing,
|
||||
});
|
||||
}
|
||||
let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs);
|
||||
return Ok(Some(PromptExpansion {
|
||||
text,
|
||||
text_elements: elements,
|
||||
}));
|
||||
}
|
||||
|
||||
// Otherwise, treat it as numeric/positional placeholder prompt (or none).
|
||||
let pos_args = parse_positional_args(rest, &local_elements);
|
||||
Ok(Some(expand_numeric_placeholders(
|
||||
&prompt.content,
|
||||
&pos_args,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
|
||||
pub fn prompt_has_numeric_placeholders(content: &str) -> bool {
|
||||
if content.contains("$ARGUMENTS") {
|
||||
return true;
|
||||
}
|
||||
let bytes = content.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'$' {
|
||||
let b1 = bytes[i + 1];
|
||||
if (b'1'..=b'9').contains(&b1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name.
|
||||
/// Returns empty when the command name does not match or when there are no args.
|
||||
pub fn extract_positional_args_for_prompt_line(
|
||||
line: &str,
|
||||
prompt_name: &str,
|
||||
text_elements: &[TextElement],
|
||||
) -> Vec<PromptArg> {
|
||||
let trimmed = line.trim_start();
|
||||
let trim_offset = line.len() - trimmed.len();
|
||||
let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Require the explicit prompts prefix for custom prompt invocations.
|
||||
let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if after_prefix != prompt_name {
|
||||
return Vec::new();
|
||||
}
|
||||
let rest_trimmed_start = rest.trim_start();
|
||||
let args_str = rest_trimmed_start.trim_end();
|
||||
if args_str.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len());
|
||||
let local_elements: Vec<TextElement> = text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
let mut shifted = shift_text_element_left(elem, args_offset)?;
|
||||
if shifted.byte_range.start >= args_str.len() {
|
||||
return None;
|
||||
}
|
||||
let end = shifted.byte_range.end.min(args_str.len());
|
||||
shifted.byte_range.end = end;
|
||||
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
|
||||
})
|
||||
.collect();
|
||||
parse_positional_args(args_str, &local_elements)
|
||||
}
|
||||
|
||||
/// If the prompt only uses numeric placeholders and the first line contains
|
||||
/// positional args for it, expand and return Some(expanded); otherwise None.
|
||||
pub fn expand_if_numeric_with_positional_args(
|
||||
prompt: &CustomPrompt,
|
||||
first_line: &str,
|
||||
text_elements: &[TextElement],
|
||||
) -> Option<PromptExpansion> {
|
||||
if !prompt_argument_names(&prompt.content).is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !prompt_has_numeric_placeholders(&prompt.content) {
|
||||
return None;
|
||||
}
|
||||
let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements);
|
||||
if args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(expand_numeric_placeholders(&prompt.content, &args))
|
||||
}
|
||||
|
||||
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
|
||||
pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion {
|
||||
let mut out = String::with_capacity(content.len());
|
||||
let mut out_elements = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(off) = content[i..].find('$') {
|
||||
let j = i + off;
|
||||
out.push_str(&content[i..j]);
|
||||
let rest = &content[j..];
|
||||
let bytes = rest.as_bytes();
|
||||
if bytes.len() >= 2 {
|
||||
match bytes[1] {
|
||||
b'$' => {
|
||||
out.push_str("$$");
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
b'1'..=b'9' => {
|
||||
let idx = (bytes[1] - b'1') as usize;
|
||||
if let Some(arg) = args.get(idx) {
|
||||
append_arg_with_elements(&mut out, &mut out_elements, arg);
|
||||
}
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
|
||||
if !args.is_empty() {
|
||||
append_joined_args_with_elements(&mut out, &mut out_elements, args);
|
||||
}
|
||||
i = j + 1 + "ARGUMENTS".len();
|
||||
continue;
|
||||
}
|
||||
out.push('$');
|
||||
i = j + 1;
|
||||
}
|
||||
out.push_str(&content[i..]);
|
||||
PromptExpansion {
|
||||
text: out,
|
||||
text_elements: out_elements,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
|
||||
let mut elements = text_elements.to_vec();
|
||||
elements.sort_by_key(|elem| elem.byte_range.start);
|
||||
// Keep element placeholders intact across shlex splitting by replacing
|
||||
// each element range with a unique sentinel token first.
|
||||
let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements);
|
||||
Shlex::new(&rest_for_shlex)
|
||||
.map(|token| apply_replacements_to_token(token, &replacements))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ElementReplacement {
|
||||
sentinel: String,
|
||||
text: String,
|
||||
placeholder: Option<String>,
|
||||
}
|
||||
|
||||
/// Replace each text element range with a unique sentinel token.
|
||||
///
|
||||
/// The sentinel is chosen so it will survive shlex tokenization as a single word.
|
||||
fn replace_text_elements_with_sentinels(
|
||||
rest: &str,
|
||||
elements: &[TextElement],
|
||||
) -> (String, Vec<ElementReplacement>) {
|
||||
let mut out = String::with_capacity(rest.len());
|
||||
let mut replacements = Vec::new();
|
||||
let mut cursor = 0;
|
||||
|
||||
for (idx, elem) in elements.iter().enumerate() {
|
||||
let start = elem.byte_range.start;
|
||||
let end = elem.byte_range.end;
|
||||
out.push_str(&rest[cursor..start]);
|
||||
let mut sentinel = format!("__CODEX_ELEM_{idx}__");
|
||||
// Ensure we never collide with user content so a sentinel can't be mistaken for text.
|
||||
while rest.contains(&sentinel) {
|
||||
sentinel.push('_');
|
||||
}
|
||||
out.push_str(&sentinel);
|
||||
replacements.push(ElementReplacement {
|
||||
sentinel,
|
||||
text: rest[start..end].to_string(),
|
||||
placeholder: elem.placeholder(rest).map(str::to_string),
|
||||
});
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
out.push_str(&rest[cursor..]);
|
||||
(out, replacements)
|
||||
}
|
||||
|
||||
/// Rehydrate a shlex token by swapping sentinels back to the original text
|
||||
/// and rebuilding text element ranges relative to the resulting token.
|
||||
fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg {
|
||||
if replacements.is_empty() {
|
||||
return PromptArg {
|
||||
text: token,
|
||||
text_elements: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(token.len());
|
||||
let mut out_elements = Vec::new();
|
||||
let mut cursor = 0;
|
||||
|
||||
while cursor < token.len() {
|
||||
let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else {
|
||||
out.push_str(&token[cursor..]);
|
||||
break;
|
||||
};
|
||||
let start_in_token = cursor + offset;
|
||||
out.push_str(&token[cursor..start_in_token]);
|
||||
let start = out.len();
|
||||
out.push_str(&replacement.text);
|
||||
let end = out.len();
|
||||
if start < end {
|
||||
out_elements.push(TextElement::new(
|
||||
ByteRange { start, end },
|
||||
replacement.placeholder.clone(),
|
||||
));
|
||||
}
|
||||
cursor = start_in_token + replacement.sentinel.len();
|
||||
}
|
||||
|
||||
PromptArg {
|
||||
text: out,
|
||||
text_elements: out_elements,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the earliest sentinel occurrence at or after `cursor`.
|
||||
fn next_replacement<'a>(
|
||||
token: &str,
|
||||
cursor: usize,
|
||||
replacements: &'a [ElementReplacement],
|
||||
) -> Option<(usize, &'a ElementReplacement)> {
|
||||
let slice = &token[cursor..];
|
||||
let mut best: Option<(usize, &'a ElementReplacement)> = None;
|
||||
for replacement in replacements {
|
||||
if let Some(pos) = slice.find(&replacement.sentinel) {
|
||||
match best {
|
||||
Some((best_pos, _)) if best_pos <= pos => {}
|
||||
_ => best = Some((pos, replacement)),
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn expand_named_placeholders_with_elements(
|
||||
content: &str,
|
||||
args: &HashMap<String, PromptArg>,
|
||||
) -> (String, Vec<TextElement>) {
|
||||
let mut out = String::with_capacity(content.len());
|
||||
let mut out_elements = Vec::new();
|
||||
let mut cursor = 0;
|
||||
for m in PROMPT_ARG_REGEX.find_iter(content) {
|
||||
let start = m.start();
|
||||
let end = m.end();
|
||||
if start > 0 && content.as_bytes()[start - 1] == b'$' {
|
||||
out.push_str(&content[cursor..end]);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
out.push_str(&content[cursor..start]);
|
||||
cursor = end;
|
||||
let key = &content[start + 1..end];
|
||||
if let Some(arg) = args.get(key) {
|
||||
append_arg_with_elements(&mut out, &mut out_elements, arg);
|
||||
} else {
|
||||
out.push_str(&content[start..end]);
|
||||
}
|
||||
}
|
||||
out.push_str(&content[cursor..]);
|
||||
(out, out_elements)
|
||||
}
|
||||
|
||||
fn append_arg_with_elements(
|
||||
out: &mut String,
|
||||
out_elements: &mut Vec<TextElement>,
|
||||
arg: &PromptArg,
|
||||
) {
|
||||
let start = out.len();
|
||||
out.push_str(&arg.text);
|
||||
if arg.text_elements.is_empty() {
|
||||
return;
|
||||
}
|
||||
out_elements.extend(arg.text_elements.iter().map(|elem| {
|
||||
elem.map_range(|range| ByteRange {
|
||||
start: start + range.start,
|
||||
end: start + range.end,
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
fn append_joined_args_with_elements(
|
||||
out: &mut String,
|
||||
out_elements: &mut Vec<TextElement>,
|
||||
args: &[PromptArg],
|
||||
) {
|
||||
// `$ARGUMENTS` joins args with single spaces while preserving element ranges.
|
||||
for (idx, arg) in args.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
append_arg_with_elements(out, out_elements, arg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a command text for a custom prompt with arguments.
|
||||
/// Returns the text and the cursor position (inside the first double quote).
|
||||
pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) {
|
||||
let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}");
|
||||
let mut cursor: usize = text.len();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
text.push_str(format!(" {arg}=\"\"").as_str());
|
||||
if i == 0 {
|
||||
cursor = text.len() - 1; // inside first ""
|
||||
}
|
||||
}
|
||||
(text, cursor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn expand_arguments_basic() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
Some(PromptExpansion {
|
||||
text: "Review Alice changes on main".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_values_ok() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Pair $USER with $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt(
|
||||
"/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main",
|
||||
&[],
|
||||
&prompts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
Some(PromptExpansion {
|
||||
text: "Pair Alice Smith with dev-main".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_arg_token_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.contains("expected key=value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_args_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.to_lowercase().contains("missing required args"));
|
||||
assert!(err.contains("BRANCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_placeholder_is_ignored() {
|
||||
assert_eq!(
|
||||
prompt_argument_names("literal $$USER"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
prompt_argument_names("literal $$USER and $REAL"),
|
||||
vec!["REAL".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_placeholder_remains_literal() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "literal $$USER".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
Some(PromptExpansion {
|
||||
text: "literal $$USER".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_args_treat_placeholder_with_spaces_as_single_token() {
|
||||
let placeholder = "[Image #1]";
|
||||
let rest = format!("alpha {placeholder} beta");
|
||||
let start = rest.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = parse_positional_args(&rest, &text_elements);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
PromptArg {
|
||||
text: "alpha".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
PromptArg {
|
||||
text: placeholder.to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
},
|
||||
PromptArg {
|
||||
text: "beta".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_positional_args_shifts_element_offsets_into_args_str() {
|
||||
let placeholder = "[Image #1]";
|
||||
let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta ");
|
||||
let start = line.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
PromptArg {
|
||||
text: "alpha".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
PromptArg {
|
||||
text: placeholder.to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
},
|
||||
PromptArg {
|
||||
text: "beta".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_args_treat_placeholder_with_spaces_as_single_token() {
|
||||
let placeholder = "[Image #1]";
|
||||
let rest = format!("IMG={placeholder} NOTE=hello");
|
||||
let start = rest.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
|
||||
assert_eq!(
|
||||
args.get("IMG"),
|
||||
Some(&PromptArg {
|
||||
text: placeholder.to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
args.get("NOTE"),
|
||||
Some(&PromptArg {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_args_allow_placeholder_inside_quotes() {
|
||||
let placeholder = "[Image #1]";
|
||||
let rest = format!("alpha \"see {placeholder} here\" beta");
|
||||
let start = rest.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = parse_positional_args(&rest, &text_elements);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
PromptArg {
|
||||
text: "alpha".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
PromptArg {
|
||||
text: format!("see {placeholder} here"),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: "see ".len(),
|
||||
end: "see ".len() + placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
},
|
||||
PromptArg {
|
||||
text: "beta".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_value_args_allow_placeholder_inside_quotes() {
|
||||
let placeholder = "[Image #1]";
|
||||
let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok");
|
||||
let start = rest.find(placeholder).expect("placeholder");
|
||||
let end = start + placeholder.len();
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start, end },
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
|
||||
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
|
||||
assert_eq!(
|
||||
args.get("IMG"),
|
||||
Some(&PromptArg {
|
||||
text: format!("see {placeholder} here"),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: "see ".len(),
|
||||
end: "see ".len() + placeholder.len(),
|
||||
},
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
args.get("NOTE"),
|
||||
Some(&PromptArg {
|
||||
text: "ok".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +180,6 @@ impl SkillPopup {
|
||||
})
|
||||
});
|
||||
|
||||
out.truncate(MAX_POPUP_ROWS);
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -235,6 +234,8 @@ fn skill_popup_hint_line() -> Line<'static> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
fn mention_item(index: usize) -> MentionItem {
|
||||
MentionItem {
|
||||
@@ -249,7 +250,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_mentions_are_capped_to_max_popup_rows() {
|
||||
fn filtered_mentions_preserve_results_beyond_popup_height() {
|
||||
let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());
|
||||
|
||||
let filtered_names: Vec<String> = popup
|
||||
@@ -260,7 +261,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
filtered_names,
|
||||
(0..MAX_POPUP_ROWS)
|
||||
(0..(MAX_POPUP_ROWS + 2))
|
||||
.map(|idx| format!("Mention {idx:02}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
@@ -269,4 +270,22 @@ mod tests {
|
||||
(MAX_POPUP_ROWS as u16) + 2
|
||||
);
|
||||
}
|
||||
|
||||
fn render_popup(popup: &SkillPopup, width: u16) -> String {
|
||||
let area = Rect::new(0, 0, width, popup.calculate_required_height(width));
|
||||
let mut buf = Buffer::empty(area);
|
||||
popup.render_ref(area, &mut buf);
|
||||
format!("{buf:?}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_mentions_shifts_rendered_window_snapshot() {
|
||||
let mut popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());
|
||||
|
||||
for _ in 0..=MAX_POPUP_ROWS {
|
||||
popup.move_down();
|
||||
}
|
||||
|
||||
insta::assert_snapshot!("skill_popup_scrolled", render_popup(&popup, /*width*/ 72));
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/skill_popup.rs
|
||||
assertion_line: 289
|
||||
expression: "render_popup(&popup, /*width*/ 72)"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 72, height: 10 },
|
||||
content: [
|
||||
" Mention 01 [Skill] Description 01 ",
|
||||
" Mention 02 [Skill] Description 02 ",
|
||||
" Mention 03 [Skill] Description 03 ",
|
||||
" Mention 04 [Skill] Description 04 ",
|
||||
" Mention 05 [Skill] Description 05 ",
|
||||
" Mention 06 [Skill] Description 06 ",
|
||||
" Mention 07 [Skill] Description 07 ",
|
||||
" Mention 08 [Skill] Description 08 ",
|
||||
" ",
|
||||
" Press enter to insert or esc to close ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 36, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 36, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 36, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 36, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 14, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 36, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 14, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 36, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 7, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 36, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 13, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 27, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 30, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -6996,9 +6996,6 @@ impl ChatWidget {
|
||||
EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev),
|
||||
EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev),
|
||||
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
|
||||
EventMsg::ListCustomPromptsResponse(_) => {
|
||||
tracing::warn!("ignoring unsupported custom prompt list response in TUI");
|
||||
}
|
||||
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
|
||||
EventMsg::SkillsUpdateAvailable => {
|
||||
self.submit_op(AppCommand::list_skills(
|
||||
|
||||
@@ -57,6 +57,9 @@ def _workspace_root_test_impl(ctx):
|
||||
)
|
||||
|
||||
runfiles = ctx.runfiles(files = [test_bin, workspace_root_marker]).merge(ctx.attr.test_bin[DefaultInfo].default_runfiles)
|
||||
for data_dep in ctx.attr.data:
|
||||
runfiles = runfiles.merge(ctx.runfiles(files = data_dep[DefaultInfo].files.to_list()))
|
||||
runfiles = runfiles.merge(data_dep[DefaultInfo].default_runfiles)
|
||||
|
||||
return [
|
||||
DefaultInfo(
|
||||
@@ -73,6 +76,9 @@ workspace_root_test = rule(
|
||||
implementation = _workspace_root_test_impl,
|
||||
test = True,
|
||||
attrs = {
|
||||
"data": attr.label_list(
|
||||
allow_files = True,
|
||||
),
|
||||
"env": attr.string_dict(),
|
||||
"test_bin": attr.label(
|
||||
cfg = "target",
|
||||
@@ -157,10 +163,29 @@ def codex_rust_crate(
|
||||
"INSTA_SNAPSHOT_PATH": "src",
|
||||
}
|
||||
|
||||
native.filegroup(
|
||||
name = "package-files",
|
||||
srcs = native.glob(
|
||||
["**"],
|
||||
exclude = [
|
||||
"**/BUILD.bazel",
|
||||
"BUILD.bazel",
|
||||
"target/**",
|
||||
],
|
||||
allow_empty = True,
|
||||
),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
rustc_env = {
|
||||
"BAZEL_PACKAGE": native.package_name(),
|
||||
} | rustc_env
|
||||
|
||||
manifest_relpath = native.package_name()
|
||||
if manifest_relpath.startswith("codex-rs/"):
|
||||
manifest_relpath = manifest_relpath[len("codex-rs/"):]
|
||||
manifest_path = manifest_relpath + "/Cargo.toml"
|
||||
|
||||
binaries = DEP_DATA.get(native.package_name())["binaries"]
|
||||
|
||||
lib_srcs = crate_srcs or native.glob(["src/**/*.rs"], exclude = binaries.values(), allow_empty = True)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Custom prompts
|
||||
|
||||
For an overview of custom prompts, see [this documentation](https://developers.openai.com/codex/custom-prompts).
|
||||
@@ -81,8 +81,6 @@ Key effects when disabled:
|
||||
|
||||
- When `popups_enabled` is `false`, `sync_popups()` forces `ActivePopup::None`.
|
||||
- When `slash_commands_enabled` is `false`, the composer does not treat `/...` input as commands.
|
||||
- When `slash_commands_enabled` is `false`, the composer does not expand custom prompts in
|
||||
`prepare_submission_text`.
|
||||
- When `slash_commands_enabled` is `false`, slash-context paste-burst exceptions are disabled.
|
||||
- When `image_paste_enabled` is `false`, file-path paste image attachment is skipped.
|
||||
- `ChatWidget` may toggle `image_paste_enabled` at runtime based on the selected model's
|
||||
@@ -107,12 +105,8 @@ the input starts with `!` (shell command).
|
||||
|
||||
1. Expands any pending paste placeholders so element ranges align with the final text.
|
||||
2. Trims whitespace and rebases element ranges to the trimmed buffer.
|
||||
3. Expands `/prompts:` custom prompts:
|
||||
- Named args use key=value parsing.
|
||||
- Numeric args use positional parsing for `$1..$9` and `$ARGUMENTS`.
|
||||
The expansion preserves text elements and yields the final submission payload.
|
||||
4. Prunes attachments so only placeholders that survive expansion are sent.
|
||||
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
|
||||
3. Prunes attachments so only placeholders that survive trimming are sent.
|
||||
4. Clears pending pastes on success and suppresses submission if the final text is empty and there
|
||||
are no attachments.
|
||||
|
||||
The same preparation path is reused for slash commands with arguments (for example `/plan` and
|
||||
@@ -124,16 +118,6 @@ still available for `Ctrl+Y`. This supports flows where a user kills part of a d
|
||||
composer action such as changing reasoning level, and then yanks that text back into the cleared
|
||||
draft.
|
||||
|
||||
### Numeric auto-submit path
|
||||
|
||||
When the slash popup is open and the first line matches a numeric-only custom prompt with
|
||||
positional args, Enter auto-submits without calling `prepare_submission_text`. That path still:
|
||||
|
||||
- Expands pending pastes before parsing positional args.
|
||||
- Uses expanded text elements for prompt expansion.
|
||||
- Prunes attachments based on expanded placeholders.
|
||||
- Clears pending pastes after a successful auto-submit.
|
||||
|
||||
## Remote image rows (selection/deletion flow)
|
||||
|
||||
Remote image URLs are shown as `[Image #N]` rows above the textarea, inside the same composer box.
|
||||
|
||||
@@ -67,13 +67,17 @@ bazel-lock-check:
|
||||
./scripts/check-module-bazel-lock.sh
|
||||
|
||||
bazel-test:
|
||||
bazel test //... --keep_going
|
||||
bazel test --test_tag_filters=-argument-comment-lint //... --keep_going
|
||||
|
||||
bazel-clippy:
|
||||
bazel build --config=clippy -- //codex-rs/... -//codex-rs/v8-poc:all
|
||||
|
||||
[no-cd]
|
||||
bazel-argument-comment-lint:
|
||||
bazel build --config=argument-comment-lint -- //codex-rs/...
|
||||
|
||||
bazel-remote-test:
|
||||
bazel test //... --config=remote --platforms=//:rbe --keep_going
|
||||
bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going
|
||||
|
||||
build-for-release:
|
||||
bazel build //codex-rs/cli:release_binaries --config=remote
|
||||
@@ -97,7 +101,11 @@ write-hooks-schema:
|
||||
# Run the argument-comment Dylint checks across codex-rs.
|
||||
[no-cd]
|
||||
argument-comment-lint *args:
|
||||
./tools/argument-comment-lint/run-prebuilt-linter.py "$@"
|
||||
if [ "$#" -eq 0 ]; then \
|
||||
bazel build --config=argument-comment-lint -- //codex-rs/...; \
|
||||
else \
|
||||
./tools/argument-comment-lint/run-prebuilt-linter.py "$@"; \
|
||||
fi
|
||||
|
||||
[no-cd]
|
||||
argument-comment-lint-from-source *args:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library")
|
||||
|
||||
exports_files(["lint_aspect.bzl"])
|
||||
|
||||
rust_library(
|
||||
name = "argument-comment-lint-lib",
|
||||
crate_name = "argument_comment_lint",
|
||||
crate_features = ["bazel_native"],
|
||||
crate_root = "src/lib.rs",
|
||||
srcs = [
|
||||
"src/comment_parser.rs",
|
||||
"src/lib.rs",
|
||||
],
|
||||
edition = "2024",
|
||||
deps = ["@argument_comment_lint_crates//:clippy_utils"],
|
||||
tags = ["manual"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
rust_binary(
|
||||
name = "argument-comment-lint-driver",
|
||||
crate_name = "argument_comment_lint_driver",
|
||||
crate_root = "driver.rs",
|
||||
srcs = ["driver.rs"],
|
||||
edition = "2024",
|
||||
deps = [
|
||||
":argument-comment-lint-lib",
|
||||
"@zlib//:z",
|
||||
],
|
||||
tags = ["manual"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -8,6 +8,9 @@ publish = false
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
bazel_native = []
|
||||
|
||||
[dependencies]
|
||||
clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "20ce69b9a63bcd2756cd906fe0964d1e901e042a" }
|
||||
dylint_linting = "5.0.0"
|
||||
|
||||
@@ -85,9 +85,11 @@ rustup toolchain install nightly-2025-09-18 \
|
||||
|
||||
The checked-in DotSlash file lives at `tools/argument-comment-lint/argument-comment-lint`.
|
||||
`run-prebuilt-linter.py` resolves that file via `dotslash` and is the path used by
|
||||
`just clippy`, `just argument-comment-lint`, and the Rust CI job. The
|
||||
source-build path remains available in `run.py` for people
|
||||
iterating on the lint crate itself.
|
||||
targeted package runs such as `just argument-comment-lint -p codex-core`.
|
||||
Repo-wide runs now go through a native Bazel aspect that invokes a custom
|
||||
`rustc_driver` and reuses Bazel-managed Rust dependency metadata instead of
|
||||
spawning `cargo dylint` once per crate. The source-build path remains available
|
||||
in `run.py` for people iterating on the lint crate itself.
|
||||
|
||||
The Unix archive layout is:
|
||||
|
||||
@@ -126,15 +128,17 @@ If you are changing the lint crate itself, use the source-build wrapper:
|
||||
Run the lint against `codex-rs` from the repo root:
|
||||
|
||||
```bash
|
||||
just argument-comment-lint
|
||||
bazel build --config=argument-comment-lint -- //codex-rs/...
|
||||
./tools/argument-comment-lint/run-prebuilt-linter.py -p codex-core
|
||||
just argument-comment-lint -p codex-core
|
||||
```
|
||||
|
||||
If no package selection is provided, `run-prebuilt-linter.py` defaults to checking the
|
||||
`codex-rs` workspace with `--workspace --no-deps`.
|
||||
For non-`--fix` runs, both wrappers also default the underlying Cargo
|
||||
invocation to `--all-targets` unless you explicitly narrow the target set, so
|
||||
workspace and package lint runs both cover test-only call sites by default.
|
||||
If no package selection is provided, `just argument-comment-lint` now defaults
|
||||
to the Bazel aspect path over `//codex-rs/...`. The Python wrappers remain the
|
||||
package-scoped escape hatch and still default the underlying Cargo invocation
|
||||
to `--all-targets` unless you explicitly narrow the target set, so targeted
|
||||
wrapper runs cover test-only call sites by default.
|
||||
|
||||
Repo runs also promote `uncommented_anonymous_literal_argument` to an error by
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#![feature(rustc_private)]
|
||||
|
||||
extern crate rustc_driver;
|
||||
extern crate rustc_interface;
|
||||
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let mut callbacks = Callbacks;
|
||||
let args = rustc_args(env::args_os().skip(1).collect());
|
||||
rustc_driver::run_compiler(&args, &mut callbacks);
|
||||
}
|
||||
|
||||
struct Callbacks;
|
||||
|
||||
impl rustc_driver::Callbacks for Callbacks {
|
||||
fn config(&mut self, config: &mut rustc_interface::Config) {
|
||||
let previous = config.register_lints.take();
|
||||
config.register_lints = Some(Box::new(move |sess, lint_store| {
|
||||
if let Some(previous) = &previous {
|
||||
previous(sess, lint_store);
|
||||
}
|
||||
argument_comment_lint::register_lints(sess, lint_store);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn rustc_args(args: Vec<OsString>) -> Vec<String> {
|
||||
let mut rustc_args: Vec<String> = args
|
||||
.into_iter()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
if rustc_args.first().is_none_or(|arg| !is_rustc(arg)) {
|
||||
rustc_args.insert(0, "rustc".to_string());
|
||||
}
|
||||
rustc_args
|
||||
}
|
||||
|
||||
fn is_rustc(arg: &str) -> bool {
|
||||
Path::new(arg).file_stem().and_then(|stem| stem.to_str()) == Some("rustc")
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Bazel aspect for running argument-comment-lint on Rust targets."""
|
||||
|
||||
load("@rules_rust//rust:defs.bzl", "rust_common")
|
||||
load("@rules_rust//rust/private:rust.bzl", "RUSTC_ATTRS")
|
||||
load(
|
||||
"@rules_rust//rust/private:rustc.bzl",
|
||||
"collect_deps",
|
||||
"collect_inputs",
|
||||
"construct_arguments",
|
||||
)
|
||||
load(
|
||||
"@rules_rust//rust/private:utils.bzl",
|
||||
"determine_output_hash",
|
||||
"find_cc_toolchain",
|
||||
"find_toolchain",
|
||||
)
|
||||
|
||||
_STRICT_LINT_FLAGS = [
|
||||
"-Duncommented-anonymous-literal-argument",
|
||||
"-Aunknown-lints",
|
||||
]
|
||||
|
||||
def _find_rustc_driver_library(toolchain):
|
||||
for file in toolchain.rustc_lib.to_list():
|
||||
if file.basename.startswith("librustc_driver-") or file.basename.startswith("rustc_driver-"):
|
||||
return file
|
||||
return None
|
||||
|
||||
def _prepend_runtime_path(env, key, path, separator):
|
||||
previous = env.get(key)
|
||||
env[key] = "{}{}{}".format(path, separator, previous) if previous else path
|
||||
|
||||
def _set_driver_runtime_env(env, toolchain):
|
||||
driver_library = _find_rustc_driver_library(toolchain)
|
||||
if not driver_library:
|
||||
return
|
||||
|
||||
library_dir = driver_library.dirname
|
||||
if driver_library.basename.endswith(".dll"):
|
||||
_prepend_runtime_path(env, "PATH", library_dir, ";")
|
||||
return
|
||||
|
||||
# The lint driver runs in exec configuration. Under remote execution the
|
||||
# exec OS can differ from the Rust target OS, so populate both Unix loader
|
||||
# variables from the located driver library instead of keying off target_os.
|
||||
_prepend_runtime_path(env, "LD_LIBRARY_PATH", library_dir, ":")
|
||||
_prepend_runtime_path(env, "DYLD_LIBRARY_PATH", library_dir, ":")
|
||||
|
||||
def _get_argument_comment_lint_ready_crate_info(target, aspect_ctx):
|
||||
if target.label.workspace_root.startswith("external"):
|
||||
return None
|
||||
|
||||
if aspect_ctx:
|
||||
ignore_tags = [
|
||||
"no_argument_comment_lint",
|
||||
"no-lint",
|
||||
"no_lint",
|
||||
"nolint",
|
||||
]
|
||||
for tag in aspect_ctx.rule.attr.tags:
|
||||
if tag.replace("-", "_").lower() in ignore_tags:
|
||||
return None
|
||||
|
||||
if rust_common.crate_info in target:
|
||||
return target[rust_common.crate_info]
|
||||
if rust_common.test_crate_info in target:
|
||||
return target[rust_common.test_crate_info].crate
|
||||
return None
|
||||
|
||||
def _rust_argument_comment_lint_aspect_impl(target, ctx):
|
||||
if OutputGroupInfo in target and hasattr(target[OutputGroupInfo], "argument_comment_lint_checks"):
|
||||
return []
|
||||
|
||||
crate_info = _get_argument_comment_lint_ready_crate_info(target, ctx)
|
||||
if not crate_info:
|
||||
return []
|
||||
|
||||
toolchain = find_toolchain(ctx)
|
||||
cc_toolchain, feature_configuration = find_cc_toolchain(ctx)
|
||||
|
||||
dep_info, build_info, _ = collect_deps(
|
||||
deps = crate_info.deps.to_list(),
|
||||
proc_macro_deps = crate_info.proc_macro_deps.to_list(),
|
||||
aliases = crate_info.aliases,
|
||||
)
|
||||
|
||||
compile_inputs, out_dir, build_env_files, build_flags_files, linkstamp_outs, ambiguous_libs = collect_inputs(
|
||||
ctx,
|
||||
ctx.rule.file,
|
||||
ctx.rule.files,
|
||||
depset([]),
|
||||
toolchain,
|
||||
cc_toolchain,
|
||||
feature_configuration,
|
||||
crate_info,
|
||||
dep_info,
|
||||
build_info,
|
||||
[],
|
||||
)
|
||||
|
||||
success_marker = ctx.actions.declare_file(
|
||||
ctx.label.name + ".argument_comment_lint.ok",
|
||||
sibling = crate_info.output,
|
||||
)
|
||||
|
||||
args, env = construct_arguments(
|
||||
ctx = ctx,
|
||||
attr = ctx.rule.attr,
|
||||
file = ctx.file,
|
||||
toolchain = toolchain,
|
||||
tool_path = ctx.executable._driver.path,
|
||||
cc_toolchain = cc_toolchain,
|
||||
feature_configuration = feature_configuration,
|
||||
crate_info = crate_info,
|
||||
dep_info = dep_info,
|
||||
linkstamp_outs = linkstamp_outs,
|
||||
ambiguous_libs = ambiguous_libs,
|
||||
output_hash = determine_output_hash(crate_info.root, ctx.label),
|
||||
rust_flags = [],
|
||||
out_dir = out_dir,
|
||||
build_env_files = build_env_files,
|
||||
build_flags_files = build_flags_files,
|
||||
emit = ["dep-info", "metadata"],
|
||||
skip_expanding_rustc_env = True,
|
||||
)
|
||||
|
||||
if crate_info.is_test:
|
||||
args.rustc_flags.add("--test")
|
||||
|
||||
args.process_wrapper_flags.add("--touch-file", success_marker)
|
||||
args.rustc_flags.add_all(_STRICT_LINT_FLAGS)
|
||||
_set_driver_runtime_env(env, toolchain)
|
||||
|
||||
driver_runfiles = ctx.attr._driver[DefaultInfo].default_runfiles.files
|
||||
action_inputs = depset(
|
||||
transitive = [
|
||||
compile_inputs,
|
||||
driver_runfiles,
|
||||
toolchain.rustc_lib,
|
||||
],
|
||||
)
|
||||
|
||||
ctx.actions.run(
|
||||
executable = ctx.executable._process_wrapper,
|
||||
inputs = action_inputs,
|
||||
outputs = [success_marker],
|
||||
env = env,
|
||||
tools = [ctx.executable._driver],
|
||||
execution_requirements = {
|
||||
"no-sandbox": "1",
|
||||
},
|
||||
arguments = args.all,
|
||||
mnemonic = "ArgumentCommentLint",
|
||||
progress_message = "ArgumentCommentLint %{label}",
|
||||
toolchain = "@rules_rust//rust:toolchain_type",
|
||||
)
|
||||
|
||||
return [OutputGroupInfo(argument_comment_lint_checks = depset([success_marker]))]
|
||||
|
||||
rust_argument_comment_lint_aspect = aspect(
|
||||
implementation = _rust_argument_comment_lint_aspect_impl,
|
||||
fragments = ["cpp"],
|
||||
attrs = {
|
||||
"_driver": attr.label(
|
||||
default = Label("//tools/argument-comment-lint:argument-comment-lint-driver"),
|
||||
executable = True,
|
||||
cfg = "exec",
|
||||
),
|
||||
} | RUSTC_ATTRS,
|
||||
toolchains = [
|
||||
str(Label("@rules_rust//rust:toolchain_type")),
|
||||
config_common.toolchain_type("@bazel_tools//tools/cpp:toolchain_type", mandatory = False),
|
||||
],
|
||||
required_providers = [
|
||||
[rust_common.crate_info],
|
||||
[rust_common.test_crate_info],
|
||||
],
|
||||
doc = """\
|
||||
Runs argument-comment-lint on Rust targets using Bazel's Rust dependency graph.
|
||||
|
||||
Example:
|
||||
|
||||
```output
|
||||
$ bazel build --config=argument-comment-lint //codex-rs/...
|
||||
```
|
||||
""",
|
||||
)
|
||||
@@ -31,6 +31,7 @@ use rustc_span::Span;
|
||||
use crate::comment_parser::parse_argument_comment;
|
||||
use crate::comment_parser::parse_argument_comment_prefix;
|
||||
|
||||
#[cfg(not(feature = "bazel_native"))]
|
||||
dylint_linting::dylint_library!();
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
|
||||
@@ -83,6 +83,26 @@ class WrapperCommonTest(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_explicit_package_manifest_does_not_force_workspace(self) -> None:
|
||||
parsed = wrapper_common.parse_wrapper_args(
|
||||
[
|
||||
"--manifest-path",
|
||||
"/tmp/custom/Cargo.toml",
|
||||
]
|
||||
)
|
||||
final_args = wrapper_common.build_final_args(parsed, Path("/repo/codex-rs/Cargo.toml"))
|
||||
|
||||
self.assertEqual(
|
||||
final_args,
|
||||
[
|
||||
"--no-deps",
|
||||
"--manifest-path",
|
||||
"/tmp/custom/Cargo.toml",
|
||||
"--",
|
||||
"--all-targets",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -107,7 +107,7 @@ def build_final_args(parsed: ParsedWrapperArgs, manifest_path: Path) -> list[str
|
||||
|
||||
if not parsed.has_manifest_path:
|
||||
final_args.extend(["--manifest-path", str(manifest_path)])
|
||||
if not parsed.has_package_selection:
|
||||
if not parsed.has_package_selection and not parsed.has_manifest_path:
|
||||
final_args.append("--workspace")
|
||||
if not parsed.has_no_deps:
|
||||
final_args.append("--no-deps")
|
||||
@@ -205,6 +205,9 @@ def ensure_source_prerequisites(env: MutableMapping[str, str]) -> None:
|
||||
|
||||
|
||||
def prefer_rustup_shims(env: MutableMapping[str, str]) -> None:
|
||||
if env.get("CODEX_ARGUMENT_COMMENT_LINT_SKIP_RUSTUP_SHIMS") == "1":
|
||||
return
|
||||
|
||||
rustup = shutil.which("rustup", path=env.get("PATH"))
|
||||
if rustup is None:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user