mirror of
https://github.com/openai/codex.git
synced 2026-02-07 09:23:47 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33ecbc2e9a | ||
|
|
55019a06e4 | ||
|
|
cf515142b0 | ||
|
|
74b2238931 | ||
|
|
cc0b5e8504 | ||
|
|
8e49a2c0d1 | ||
|
|
af1ed2685e | ||
|
|
1a0e2e612b | ||
|
|
acfd94f625 |
3
.bazelignore
Normal file
3
.bazelignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# Without this, Bazel will consider BUILD.bazel files in
|
||||
# .git/sl/origbackups (which can be populated by Sapling SCM).
|
||||
.git
|
||||
7
.bazelrc
7
.bazelrc
@@ -39,7 +39,10 @@ common --grpc_keepalive_time=30s
|
||||
# memory in exchange for higher download concurrency.
|
||||
common --jobs=30
|
||||
|
||||
# These configs are split so linux CI can configure a custom exec platform.
|
||||
common:remote --extra_execution_platforms=//:rbe
|
||||
common:remote --remote_executor=grpcs://remote.buildbuddy.io
|
||||
common:remote --jobs=800
|
||||
common:remote --config=remote-base
|
||||
|
||||
common:remote-base --remote_executor=grpcs://remote.buildbuddy.io
|
||||
common:remote-base --jobs=800
|
||||
|
||||
|
||||
2
.github/workflows/Dockerfile.bazel
vendored
2
.github/workflows/Dockerfile.bazel
vendored
@@ -4,7 +4,7 @@ FROM ubuntu:24.04
|
||||
# initial debugging, but we should publish to a more proper location.
|
||||
#
|
||||
# docker buildx create --use
|
||||
# docker buildx build --platform linux/amd64 -f .github/workflows/Dockerfile.bazel -t mbolin491/codex-bazel:latest --push .
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 -f .github/workflows/Dockerfile.bazel -t mbolin491/codex-bazel:latest --push .
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
12
.github/workflows/bazel.yml
vendored
12
.github/workflows/bazel.yml
vendored
@@ -97,14 +97,24 @@ jobs:
|
||||
# Use a very short path to reduce argv/path length issues.
|
||||
"BAZEL_STARTUP_ARGS=--output_user_root=C:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Configure Bazel startup args (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "BAZEL_STARTUP_ARGS=--bazelrc=.github/workflows/linux.bazelrc" >> "$GITHUB_ENV"
|
||||
|
||||
- name: bazel test //...
|
||||
env:
|
||||
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
target="${{ matrix.target }}"
|
||||
host_arch="${target%%-*}" # e.g. aarch64 / x86_64
|
||||
|
||||
bazel $BAZEL_STARTUP_ARGS --bazelrc=.github/workflows/ci.bazelrc test //... \
|
||||
--config="$host_arch" \
|
||||
--build_metadata=REPO_URL=https://github.com/openai/codex.git \
|
||||
--build_metadata=COMMIT_SHA=$(git rev-parse HEAD) \
|
||||
--build_metadata=ROLE=CI \
|
||||
--build_metadata=VISIBILITY=PUBLIC \
|
||||
"--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY"
|
||||
"--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY"
|
||||
22
.github/workflows/ci.bazelrc
vendored
22
.github/workflows/ci.bazelrc
vendored
@@ -1,15 +1,23 @@
|
||||
common --remote_download_minimal
|
||||
common --nobuild_runfile_links
|
||||
common --keep_going
|
||||
|
||||
# Prefer to run the build actions entirely remotely so we can dial up the concurrency.
|
||||
# Currently remote builds only work on Mac hosts, until we untangle the libc constraints mess on linux.
|
||||
# These config settings are used to route linux RBE actions, but the linux bazelrc is included conditionally.
|
||||
# This ensures that the configs are defined on non-linux as well.
|
||||
common:aarch64 --keep_going
|
||||
common:x86_64 --keep_going
|
||||
|
||||
# We prefer to run the build actions entirely remotely so we can dial up the concurrency.
|
||||
# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform.
|
||||
|
||||
# On linux, we can do a full remote build/test, by targeting the right (x86/arm) runners, so we have coverage of both.
|
||||
# Linux crossbuilds don't work until we untangle the libc constraint mess.
|
||||
common:linux --config=remote-base
|
||||
common:linux --strategy=remote
|
||||
|
||||
# On mac, we can run all the build actions remotely but test actions locally.
|
||||
common:macos --config=remote
|
||||
common:macos --strategy=remote
|
||||
|
||||
# We have platform-specific tests, so execute the tests locally using the strongest sandboxing available on each platform.
|
||||
common:macos --strategy=TestRunner=darwin-sandbox,local
|
||||
# Note: linux-sandbox is stronger, but not available in GHA.
|
||||
common:linux --strategy=TestRunner=processwrapper-sandbox,local
|
||||
|
||||
common:windows --strategy=TestRunner=local
|
||||
|
||||
|
||||
4
.github/workflows/linux.bazelrc
vendored
Normal file
4
.github/workflows/linux.bazelrc
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
common:aarch64 --extra_execution_platforms=//:rbe_arm64
|
||||
common:aarch64 --platforms=//:rbe_arm64
|
||||
common:x86_64 --extra_execution_platforms=//:rbe
|
||||
common:x86_64 --platforms=//:rbe
|
||||
21
BUILD.bazel
21
BUILD.bazel
@@ -24,6 +24,27 @@ platform(
|
||||
# tools that various integration tests need.
|
||||
# Verify at https://hub.docker.com/layers/mbolin491/codex-bazel/latest/images/sha256:8c9ff94187ea7c08a31e9a81f5fe8046ea3972a6768983c955c4079fa30567fb
|
||||
"container-image": "docker://docker.io/mbolin491/codex-bazel@sha256:8c9ff94187ea7c08a31e9a81f5fe8046ea3972a6768983c955c4079fa30567fb",
|
||||
"Arch": "amd64",
|
||||
"OSFamily": "Linux",
|
||||
},
|
||||
)
|
||||
|
||||
platform(
|
||||
name = "rbe_arm64",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:aarch64",
|
||||
"@platforms//os:linux",
|
||||
"@bazel_tools//tools/cpp:clang",
|
||||
"@toolchains_llvm_bootstrapped//constraints/libc:gnu.2.28",
|
||||
],
|
||||
exec_properties = {
|
||||
# Ubuntu-based image that includes git, python3, dotslash, and other
|
||||
# tools that various integration tests need.
|
||||
# Verify at https://hub.docker.com/layers/mbolin491/codex-bazel/latest/images/sha256:ad9506086215fccfc66ed8d2be87847324be56790ae6a1964c241c28b77ef141
|
||||
"container-image": "docker://docker.io/mbolin491/codex-bazel@sha256:ad9506086215fccfc66ed8d2be87847324be56790ae6a1964c241c28b77ef141",
|
||||
"Arch": "arm64",
|
||||
"OSFamily": "Linux",
|
||||
},
|
||||
)
|
||||
|
||||
exports_files(["AGENTS.md"])
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Example announcement tips for Codex TUI.
|
||||
# Each [[announcements]] entry is evaluated in order; the last matching one is shown.
|
||||
# Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive.
|
||||
# version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions.
|
||||
# target_app specify which app should display the announcement (cli, vsce, ...).
|
||||
|
||||
[[announcements]]
|
||||
content = "Welcome to Codex! Check out the new onboarding flow."
|
||||
from_date = "2024-10-01"
|
||||
to_date = "2024-10-15"
|
||||
target_app = "cli"
|
||||
|
||||
[[announcements]]
|
||||
content = "This is a test announcement"
|
||||
version_regex = "^0\\.0\\.0$"
|
||||
to_date = "2026-01-10"
|
||||
@@ -7,6 +7,7 @@ pub enum TransportError {
|
||||
#[error("http {status}: {body:?}")]
|
||||
Http {
|
||||
status: StatusCode,
|
||||
url: Option<String>,
|
||||
headers: Option<HeaderMap>,
|
||||
body: Option<String>,
|
||||
},
|
||||
|
||||
@@ -131,6 +131,7 @@ impl HttpTransport for ReqwestTransport {
|
||||
);
|
||||
}
|
||||
|
||||
let url = req.url.clone();
|
||||
let builder = self.build(req)?;
|
||||
let resp = builder.send().await.map_err(Self::map_error)?;
|
||||
let status = resp.status();
|
||||
@@ -140,6 +141,7 @@ impl HttpTransport for ReqwestTransport {
|
||||
let body = String::from_utf8(bytes.to_vec()).ok();
|
||||
return Err(TransportError::Http {
|
||||
status,
|
||||
url: Some(url),
|
||||
headers: Some(headers),
|
||||
body,
|
||||
});
|
||||
@@ -161,6 +163,7 @@ impl HttpTransport for ReqwestTransport {
|
||||
);
|
||||
}
|
||||
|
||||
let url = req.url.clone();
|
||||
let builder = self.build(req)?;
|
||||
let resp = builder.send().await.map_err(Self::map_error)?;
|
||||
let status = resp.status();
|
||||
@@ -169,6 +172,7 @@ impl HttpTransport for ReqwestTransport {
|
||||
let body = resp.text().await.ok();
|
||||
return Err(TransportError::Http {
|
||||
status,
|
||||
url: Some(url),
|
||||
headers: Some(headers),
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,15 @@ codex_rust_crate(
|
||||
"//codex-rs/apply-patch:apply_patch_tool_instructions.md",
|
||||
"prompt.md",
|
||||
],
|
||||
# This is a bit of a hack, but empirically, some of our integration tests
|
||||
# are relying on the presence of this file as a repo root marker. When
|
||||
# running tests locally, this "just works," but in remote execution,
|
||||
# the working directory is different and so the file is not found unless it
|
||||
# is explicitly added as test data.
|
||||
#
|
||||
# TODO(aibrahim): Update the tests so that `just bazel-remote-test` succeeds
|
||||
# without this workaround.
|
||||
test_data_extra = ["//:AGENTS.md"],
|
||||
integration_deps_extra = ["//codex-rs/core/tests/common:common"],
|
||||
test_tags = ["no-sandbox"],
|
||||
extra_binaries = [
|
||||
|
||||
7
codex-rs/core/hierarchical_agents_message.md
Normal file
7
codex-rs/core/hierarchical_agents_message.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Files called AGENTS.md commonly appear in many places inside a container - at "/", in "~", deep within git repositories, or in any other directory; their location is not limited to version-controlled folders.
|
||||
|
||||
Their purpose is to pass along human guidance to you, the agent. Such guidance can include coding standards, explanations of the project layout, steps for building or testing, and even wording that must accompany a GitHub pull-request description produced by the agent; all of it is to be followed.
|
||||
|
||||
Each AGENTS.md governs the entire directory that contains it and every child directory beneath that point. Whenever you change a file, you have to comply with every AGENTS.md whose scope covers that file. Naming conventions, stylistic rules and similar directives are restricted to the code that falls inside that scope unless the document explicitly states otherwise.
|
||||
|
||||
When two AGENTS.md files disagree, the one located deeper in the directory structure overrides the higher-level file, while instructions given directly in the prompt by the system, developer, or user outrank any AGENTS.md content.
|
||||
@@ -25,11 +25,13 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
ApiError::Api { status, message } => CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: message,
|
||||
url: None,
|
||||
request_id: None,
|
||||
}),
|
||||
ApiError::Transport(transport) => match transport {
|
||||
TransportError::Http {
|
||||
status,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
} => {
|
||||
@@ -71,6 +73,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: body_text,
|
||||
url,
|
||||
request_id: extract_request_id(headers.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -533,6 +533,7 @@ async fn handle_unauthorized(
|
||||
fn map_unauthorized_status(status: StatusCode) -> CodexErr {
|
||||
map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status,
|
||||
url: None,
|
||||
headers: None,
|
||||
body: None,
|
||||
}))
|
||||
|
||||
@@ -277,6 +277,7 @@ pub enum RefreshTokenFailedReason {
|
||||
pub struct UnexpectedResponseError {
|
||||
pub status: StatusCode,
|
||||
pub body: String,
|
||||
pub url: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -293,7 +294,11 @@ impl UnexpectedResponseError {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {})", self.status);
|
||||
let status = self.status;
|
||||
let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})");
|
||||
if let Some(url) = &self.url {
|
||||
message.push_str(&format!(", url: {url}"));
|
||||
}
|
||||
if let Some(id) = &self.request_id {
|
||||
message.push_str(&format!(", request id: {id}"));
|
||||
}
|
||||
@@ -307,16 +312,16 @@ impl std::fmt::Display for UnexpectedResponseError {
|
||||
if let Some(friendly) = self.friendly_message() {
|
||||
write!(f, "{friendly}")
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"unexpected status {}: {}{}",
|
||||
self.status,
|
||||
self.body,
|
||||
self.request_id
|
||||
.as_ref()
|
||||
.map(|id| format!(", request id: {id}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
let status = self.status;
|
||||
let body = &self.body;
|
||||
let mut message = format!("unexpected status {status}: {body}");
|
||||
if let Some(url) = &self.url {
|
||||
message.push_str(&format!(", url: {url}"));
|
||||
}
|
||||
if let Some(id) = &self.request_id {
|
||||
message.push_str(&format!(", request id: {id}"));
|
||||
}
|
||||
write!(f, "{message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -826,12 +831,16 @@ mod tests {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
body: "<html><body>Cloudflare error: Sorry, you have been blocked</body></html>"
|
||||
.to_string(),
|
||||
url: Some("http://example.com/blocked".to_string()),
|
||||
request_id: Some("ray-id".to_string()),
|
||||
};
|
||||
let status = StatusCode::FORBIDDEN.to_string();
|
||||
let url = "http://example.com/blocked";
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), request id: ray-id")
|
||||
format!(
|
||||
"{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, request id: ray-id"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -840,12 +849,14 @@ mod tests {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
body: "plain text error".to_string(),
|
||||
url: Some("http://example.com/plain".to_string()),
|
||||
request_id: None,
|
||||
};
|
||||
let status = StatusCode::FORBIDDEN.to_string();
|
||||
let url = "http://example.com/plain";
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("unexpected status {status}: plain text error")
|
||||
format!("unexpected status {status}: plain text error, url: {url}")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,8 @@ pub enum Feature {
|
||||
RemoteModels,
|
||||
/// Experimental shell snapshotting.
|
||||
ShellSnapshot,
|
||||
/// Append additional AGENTS.md guidance to user instructions.
|
||||
HierarchicalAgents,
|
||||
/// Experimental TUI v2 (viewport) implementation.
|
||||
Tui2,
|
||||
/// Enforce UTF8 output in Powershell.
|
||||
@@ -352,6 +354,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::HierarchicalAgents,
|
||||
key: "hierarchical_agents",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ApplyPatchFreeform,
|
||||
key: "apply_patch_freeform",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! 3. We do **not** walk past the Git root.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::features::Feature;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::render_skills_section;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
@@ -21,6 +22,9 @@ use std::path::PathBuf;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tracing::error;
|
||||
|
||||
pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str =
|
||||
include_str!("../hierarchical_agents_message.md");
|
||||
|
||||
/// Default filename scanned for project-level docs.
|
||||
pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md";
|
||||
/// Preferred local override for project-level docs.
|
||||
@@ -36,35 +40,46 @@ pub(crate) async fn get_user_instructions(
|
||||
config: &Config,
|
||||
skills: Option<&[SkillMetadata]>,
|
||||
) -> Option<String> {
|
||||
let skills_section = skills.and_then(render_skills_section);
|
||||
let project_docs = read_project_docs(config).await;
|
||||
|
||||
let project_docs = match read_project_docs(config).await {
|
||||
Ok(docs) => docs,
|
||||
let mut output = String::new();
|
||||
|
||||
if let Some(instructions) = config.user_instructions.clone() {
|
||||
output.push_str(&instructions);
|
||||
}
|
||||
|
||||
match project_docs {
|
||||
Ok(Some(docs)) => {
|
||||
if !output.is_empty() {
|
||||
output.push_str(PROJECT_DOC_SEPARATOR);
|
||||
}
|
||||
output.push_str(&docs);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
error!("error trying to find project doc: {e:#}");
|
||||
return config.user_instructions.clone();
|
||||
}
|
||||
};
|
||||
|
||||
let combined_project_docs = merge_project_docs_with_skills(project_docs, skills_section);
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(instructions) = config.user_instructions.clone() {
|
||||
parts.push(instructions);
|
||||
}
|
||||
|
||||
if let Some(project_doc) = combined_project_docs {
|
||||
if !parts.is_empty() {
|
||||
parts.push(PROJECT_DOC_SEPARATOR.to_string());
|
||||
let skills_section = skills.and_then(render_skills_section);
|
||||
if let Some(skills_section) = skills_section {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
parts.push(project_doc);
|
||||
output.push_str(&skills_section);
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
if config.features.enabled(Feature::HierarchicalAgents) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(HIERARCHICAL_AGENTS_MESSAGE);
|
||||
}
|
||||
|
||||
if !output.is_empty() {
|
||||
Some(output)
|
||||
} else {
|
||||
Some(parts.concat())
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,18 +232,6 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
|
||||
names
|
||||
}
|
||||
|
||||
fn merge_project_docs_with_skills(
|
||||
project_doc: Option<String>,
|
||||
skills_section: Option<String>,
|
||||
) -> Option<String> {
|
||||
match (project_doc, skills_section) {
|
||||
(Some(doc), Some(skills)) => Some(format!("{doc}\n\n{skills}")),
|
||||
(Some(doc), None) => Some(doc),
|
||||
(None, Some(skills)) => Some(skills),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
71
codex-rs/core/tests/suite/hierarchical_agents.rs
Normal file
71
codex-rs/core/tests/suite/hierarchical_agents.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use codex_core::features::Feature;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
|
||||
const HIERARCHICAL_AGENTS_SNIPPET: &str =
|
||||
"Files called AGENTS.md commonly appear in many places inside a container";
|
||||
|
||||
fn sse_completed(id: &str) -> String {
|
||||
load_sse_fixture_with_id("../fixtures/completed_template.json", id)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() {
|
||||
let server = start_mock_server().await;
|
||||
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.features.enable(Feature::HierarchicalAgents);
|
||||
std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md");
|
||||
});
|
||||
let test = builder.build(&server).await.expect("build test codex");
|
||||
|
||||
test.submit_turn("hello").await.expect("submit turn");
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let user_messages = request.message_input_texts("user");
|
||||
let instructions = user_messages
|
||||
.iter()
|
||||
.find(|text| text.starts_with("# AGENTS.md instructions for "))
|
||||
.expect("instructions message");
|
||||
assert!(
|
||||
instructions.contains("be nice"),
|
||||
"expected AGENTS.md text included: {instructions}"
|
||||
);
|
||||
let snippet_pos = instructions
|
||||
.find(HIERARCHICAL_AGENTS_SNIPPET)
|
||||
.expect("expected hierarchical agents snippet");
|
||||
let base_pos = instructions
|
||||
.find("be nice")
|
||||
.expect("expected AGENTS.md text");
|
||||
assert!(
|
||||
snippet_pos > base_pos,
|
||||
"expected hierarchical agents message appended after base instructions: {instructions}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hierarchical_agents_emits_when_no_project_doc() {
|
||||
let server = start_mock_server().await;
|
||||
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.features.enable(Feature::HierarchicalAgents);
|
||||
});
|
||||
let test = builder.build(&server).await.expect("build test codex");
|
||||
|
||||
test.submit_turn("hello").await.expect("submit turn");
|
||||
|
||||
let request = resp_mock.single_request();
|
||||
let user_messages = request.message_input_texts("user");
|
||||
let instructions = user_messages
|
||||
.iter()
|
||||
.find(|text| text.starts_with("# AGENTS.md instructions for "))
|
||||
.expect("instructions message");
|
||||
assert!(
|
||||
instructions.contains(HIERARCHICAL_AGENTS_SNIPPET),
|
||||
"expected hierarchical agents message appended: {instructions}"
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ mod exec;
|
||||
mod exec_policy;
|
||||
mod fork_thread;
|
||||
mod grep_files;
|
||||
mod hierarchical_agents;
|
||||
mod items;
|
||||
mod json_result;
|
||||
mod list_dir;
|
||||
|
||||
@@ -4,9 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::error::CodexErr;
|
||||
@@ -39,6 +37,8 @@ use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -98,19 +98,19 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
)
|
||||
.await;
|
||||
|
||||
let harness = build_remote_models_harness(&server, |config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
})
|
||||
.await?;
|
||||
|
||||
let RemoteModelsHarness {
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
config,
|
||||
thread_manager,
|
||||
..
|
||||
} = harness;
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let models_manager = thread_manager.get_models_manager();
|
||||
let available_model =
|
||||
@@ -214,16 +214,19 @@ async fn remote_models_truncation_policy_without_override_preserves_remote() ->
|
||||
)
|
||||
.await;
|
||||
|
||||
let harness = build_remote_models_harness(&server, |config| {
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
})
|
||||
.await?;
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let models_manager = harness.thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, slug, &harness.config).await;
|
||||
let models_manager = test.thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, slug, &test.config).await;
|
||||
|
||||
let model_info = models_manager
|
||||
.construct_model_info(slug, &harness.config)
|
||||
.construct_model_info(slug, &test.config)
|
||||
.await;
|
||||
assert_eq!(
|
||||
model_info.truncation_policy,
|
||||
@@ -258,17 +261,20 @@ async fn remote_models_truncation_policy_with_tool_output_override() -> Result<(
|
||||
)
|
||||
.await;
|
||||
|
||||
let harness = build_remote_models_harness(&server, |config| {
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
config.tool_output_token_limit = Some(50);
|
||||
})
|
||||
.await?;
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
config.tool_output_token_limit = Some(50);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let models_manager = harness.thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, slug, &harness.config).await;
|
||||
let models_manager = test.thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, slug, &test.config).await;
|
||||
|
||||
let model_info = models_manager
|
||||
.construct_model_info(slug, &harness.config)
|
||||
.construct_model_info(slug, &test.config)
|
||||
.await;
|
||||
assert_eq!(
|
||||
model_info.truncation_policy,
|
||||
@@ -335,19 +341,19 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
)
|
||||
.await;
|
||||
|
||||
let harness = build_remote_models_harness(&server, |config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
})
|
||||
.await?;
|
||||
|
||||
let RemoteModelsHarness {
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
config.model = Some("gpt-5.1".to_string());
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
config,
|
||||
thread_manager,
|
||||
..
|
||||
} = harness;
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let models_manager = thread_manager.get_models_manager();
|
||||
wait_for_model_available(&models_manager, model, &config).await;
|
||||
@@ -577,50 +583,6 @@ async fn wait_for_model_available(
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteModelsHarness {
|
||||
codex: Arc<CodexThread>,
|
||||
cwd: Arc<TempDir>,
|
||||
config: Config,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
}
|
||||
|
||||
// todo(aibrahim): move this to with_model_provier in test_codex
|
||||
async fn build_remote_models_harness<F>(
|
||||
server: &MockServer,
|
||||
mutate_config: F,
|
||||
) -> Result<RemoteModelsHarness>
|
||||
where
|
||||
F: FnOnce(&mut Config),
|
||||
{
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let cwd = Arc::new(TempDir::new()?);
|
||||
|
||||
let mut config = load_default_config_for_test(&home).await;
|
||||
config.cwd = cwd.path().to_path_buf();
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
|
||||
let provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
config.model_provider = provider.clone();
|
||||
|
||||
mutate_config(&mut config);
|
||||
|
||||
let thread_manager = ThreadManager::with_models_provider(auth, provider);
|
||||
let thread_manager = Arc::new(thread_manager);
|
||||
|
||||
let new_conversation = thread_manager.start_thread(config.clone()).await?;
|
||||
|
||||
Ok(RemoteModelsHarness {
|
||||
codex: new_conversation.thread,
|
||||
cwd,
|
||||
config,
|
||||
thread_manager,
|
||||
})
|
||||
}
|
||||
|
||||
fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo {
|
||||
test_remote_model_with_policy(
|
||||
slug,
|
||||
|
||||
@@ -17,6 +17,7 @@ use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
use super::account::StatusAccountDisplay;
|
||||
use super::format::FieldFormatter;
|
||||
@@ -62,6 +63,7 @@ struct StatusHistoryCell {
|
||||
approval: String,
|
||||
sandbox: String,
|
||||
agents_summary: String,
|
||||
model_provider: Option<String>,
|
||||
account: Option<StatusAccountDisplay>,
|
||||
session_id: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
@@ -129,6 +131,7 @@ impl StatusHistoryCell {
|
||||
}
|
||||
};
|
||||
let agents_summary = compose_agents_summary(config);
|
||||
let model_provider = format_model_provider(config);
|
||||
let account = compose_account_display(auth_manager, plan_type);
|
||||
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
|
||||
let default_usage = TokenUsage::default();
|
||||
@@ -157,6 +160,7 @@ impl StatusHistoryCell {
|
||||
approval,
|
||||
sandbox,
|
||||
agents_summary,
|
||||
model_provider,
|
||||
account,
|
||||
session_id,
|
||||
token_usage,
|
||||
@@ -338,6 +342,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
.collect();
|
||||
let mut seen: BTreeSet<String> = labels.iter().cloned().collect();
|
||||
|
||||
if self.model_provider.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Model provider");
|
||||
}
|
||||
if account_value.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Account");
|
||||
}
|
||||
@@ -381,6 +388,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
let directory_value = format_directory_display(&self.directory, Some(value_width));
|
||||
|
||||
lines.push(formatter.line("Model", model_spans));
|
||||
if let Some(model_provider) = self.model_provider.as_ref() {
|
||||
lines.push(formatter.line("Model provider", vec![Span::from(model_provider.clone())]));
|
||||
}
|
||||
lines.push(formatter.line("Directory", vec![Span::from(directory_value)]));
|
||||
lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())]));
|
||||
lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())]));
|
||||
@@ -416,3 +426,39 @@ impl HistoryCell for StatusHistoryCell {
|
||||
with_border_with_inner_width(truncated_lines, inner_width)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_model_provider(config: &Config) -> Option<String> {
|
||||
let provider = &config.model_provider;
|
||||
let name = provider.name.trim();
|
||||
let provider_name = if name.is_empty() {
|
||||
config.model_provider_id.as_str()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let base_url = provider.base_url.as_deref().and_then(sanitize_base_url);
|
||||
let is_default_openai = provider.is_openai() && base_url.is_none();
|
||||
if is_default_openai {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match base_url {
|
||||
Some(base_url) => format!("{provider_name} - {base_url}"),
|
||||
None => provider_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_base_url(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Ok(mut url) = Url::parse(trimmed) else {
|
||||
return None;
|
||||
};
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string().trim_end_matches('/').to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
use super::account::StatusAccountDisplay;
|
||||
use super::format::FieldFormatter;
|
||||
@@ -62,6 +63,7 @@ struct StatusHistoryCell {
|
||||
approval: String,
|
||||
sandbox: String,
|
||||
agents_summary: String,
|
||||
model_provider: Option<String>,
|
||||
account: Option<StatusAccountDisplay>,
|
||||
session_id: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
@@ -129,6 +131,7 @@ impl StatusHistoryCell {
|
||||
}
|
||||
};
|
||||
let agents_summary = compose_agents_summary(config);
|
||||
let model_provider = format_model_provider(config);
|
||||
let account = compose_account_display(auth_manager, plan_type);
|
||||
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
|
||||
let default_usage = TokenUsage::default();
|
||||
@@ -157,6 +160,7 @@ impl StatusHistoryCell {
|
||||
approval,
|
||||
sandbox,
|
||||
agents_summary,
|
||||
model_provider,
|
||||
account,
|
||||
session_id,
|
||||
token_usage,
|
||||
@@ -338,6 +342,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
.collect();
|
||||
let mut seen: BTreeSet<String> = labels.iter().cloned().collect();
|
||||
|
||||
if self.model_provider.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Model provider");
|
||||
}
|
||||
if account_value.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Account");
|
||||
}
|
||||
@@ -380,6 +387,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
let directory_value = format_directory_display(&self.directory, Some(value_width));
|
||||
|
||||
lines.push(formatter.line("Model", model_spans));
|
||||
if let Some(model_provider) = self.model_provider.as_ref() {
|
||||
lines.push(formatter.line("Model provider", vec![Span::from(model_provider.clone())]));
|
||||
}
|
||||
lines.push(formatter.line("Directory", vec![Span::from(directory_value)]));
|
||||
lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())]));
|
||||
lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())]));
|
||||
@@ -415,3 +425,39 @@ impl HistoryCell for StatusHistoryCell {
|
||||
with_border_with_inner_width(truncated_lines, inner_width)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_model_provider(config: &Config) -> Option<String> {
|
||||
let provider = &config.model_provider;
|
||||
let name = provider.name.trim();
|
||||
let provider_name = if name.is_empty() {
|
||||
config.model_provider_id.as_str()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let base_url = provider.base_url.as_deref().and_then(sanitize_base_url);
|
||||
let is_default_openai = provider.is_openai() && base_url.is_none();
|
||||
if is_default_openai {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match base_url {
|
||||
Some(base_url) => format!("{provider_name} - {base_url}"),
|
||||
None => provider_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_base_url(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Ok(mut url) = Url::parse(trimmed) else {
|
||||
return None;
|
||||
};
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string().trim_end_matches('/').to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# AGENTS.md
|
||||
|
||||
For information about AGENTS.md, see [this documentation](https://developers.openai.com/codex/guides/agents-md).
|
||||
|
||||
## Hierarchical agents message
|
||||
|
||||
When the `hierarchical_agents` feature flag is enabled (via `[features]` in `config.toml`), Codex appends additional guidance about AGENTS.md scope and precedence to the user instructions message and emits that message even when no AGENTS.md is present.
|
||||
|
||||
Reference in New Issue
Block a user