Compare commits

...

16 Commits

Author SHA1 Message Date
pap
cec51479de functions return either path or content, therefore function returning content re-use path functions 2025-08-07 17:04:26 +01:00
pap-openai
b3130e324d Merge branch 'main' into feature/agents-md-launch 2025-08-07 16:55:10 +01:00
pap
5bfd4242b8 remove agents.md at launch but show in /status 2025-08-07 16:54:58 +01:00
pap
4697623888 fmt 2025-08-07 12:23:04 +01:00
pap
159510fa38 status rendering 2025-08-07 12:17:15 +01:00
pap
5f41b6040f Merge branch 'main' into feature/agents-md-launch 2025-08-07 12:06:09 +01:00
pap
a4aeba1282 fixing rendering behavior 2025-08-07 11:41:17 +01:00
Ed Bayes
4d0dc29f3e Update copy (#1935)
Updated copy

---------

Co-authored-by: pap-openai <pap@openai.com>
2025-08-07 11:36:48 +01:00
Charlie Weems
39f47f2733 Fix opacity of agents.md string 2025-08-07 02:07:34 -07:00
Charlie Weems
7b94347761 Merge branch 'main' into feature/agents-md-launch 2025-08-07 02:03:17 -07:00
pap-openai
d4db71466f Merge branch 'main' into feature/agents-md-launch 2025-08-07 08:53:15 +01:00
easong-openai
ca4cfbe9ef Merge branch 'main' into feature/agents-md-launch 2025-08-07 00:42:04 -07:00
pap
c974655475 fixing /status output 2025-08-07 00:57:02 +01:00
pap
b75fb20be4 fmt 2025-08-07 00:42:05 +01:00
pap
773ab49050 make the display better 2025-08-07 00:39:02 +01:00
pap
72f54926ad showing agents.md at start 2025-08-07 00:20:56 +01:00
4 changed files with 196 additions and 75 deletions

View File

@@ -29,6 +29,9 @@ use toml::Value as TomlValue;
/// the context window.
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
/// Candidate filenames for user-provided instructions stored in CODEX_HOME.
pub(crate) const USER_INSTRUCTIONS_CANDIDATE_FILENAMES: &[&str] = &["AGENTS.md"];
/// Application configuration loaded from disk and merged with overrides.
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
@@ -576,13 +579,9 @@ impl Config {
}
fn load_instructions(codex_dir: Option<&Path>) -> Option<String> {
let mut p = match codex_dir {
Some(p) => p.to_path_buf(),
None => return None,
};
p.push("AGENTS.md");
std::fs::read_to_string(&p).ok().and_then(|s| {
let dir = codex_dir?;
let path = find_user_instructions_path(dir)?;
std::fs::read_to_string(&path).ok().and_then(|s| {
let s = s.trim();
if s.is_empty() {
None
@@ -674,6 +673,20 @@ pub fn log_dir(cfg: &Config) -> std::io::Result<PathBuf> {
Ok(p)
}
/// Returns the path to the first non-empty user instructions file inside the
/// provided CODEX_HOME directory, if any.
pub(crate) fn find_user_instructions_path(codex_dir: &Path) -> Option<PathBuf> {
for name in USER_INSTRUCTIONS_CANDIDATE_FILENAMES {
let candidate = codex_dir.join(name);
if let Ok(s) = std::fs::read_to_string(&candidate) {
if !s.trim().is_empty() {
return Some(candidate);
}
}
}
None
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]

View File

@@ -38,7 +38,7 @@ mod models;
mod openai_model_info;
mod openai_tools;
pub mod plan_tool;
mod project_doc;
pub mod project_doc;
pub mod protocol;
mod rollout;
pub(crate) mod safety;

View File

@@ -12,7 +12,9 @@
//! exists, the search stops we do **not** walk past the Git root.
use crate::config::Config;
use crate::config::find_user_instructions_path;
use std::path::Path;
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use tracing::error;
@@ -50,86 +52,166 @@ pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
/// `Err` so callers can decide how to handle them.
async fn find_project_doc(config: &Config) -> std::io::Result<Option<String>> {
let max_bytes = config.project_doc_max_bytes;
// Attempt to load from the working directory first.
if let Some(doc) = load_first_candidate(&config.cwd, CANDIDATE_FILENAMES, max_bytes).await? {
return Ok(Some(doc));
if max_bytes == 0 {
return Ok(None);
}
// Walk up towards the filesystem root, stopping once we encounter the Git
// repository root. The presence of **either** a `.git` *file* or
// *directory* counts.
let mut dir = config.cwd.clone();
// Canonicalize the path so that we do not end up in an infinite loop when
// `cwd` contains `..` components.
if let Ok(canon) = dir.canonicalize() {
dir = canon;
}
while let Some(parent) = dir.parent() {
// `.git` can be a *file* (for worktrees or submodules) or a *dir*.
let git_marker = dir.join(".git");
let git_exists = match tokio::fs::metadata(&git_marker).await {
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => return Err(e),
};
if git_exists {
// We are at the repo root attempt one final load.
if let Some(doc) = load_first_candidate(&dir, CANDIDATE_FILENAMES, max_bytes).await? {
return Ok(Some(doc));
}
break;
}
dir = parent.to_path_buf();
if let Some(path) = find_project_doc_path_async(config).await {
return read_file_with_limit(&path, max_bytes).await;
}
Ok(None)
}
/// Attempt to load the first candidate file found in `dir`. Returns the file
/// contents (truncated if it exceeds `max_bytes`) when successful.
async fn load_first_candidate(
dir: &Path,
names: &[&str],
max_bytes: usize,
) -> std::io::Result<Option<String>> {
for name in names {
let candidate = dir.join(name);
/// Lightweight description of where user and project instruction files were
/// sourced from. Paths are absolute and only present when a corresponding file
/// exists and is non-empty.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstructionsInfo {
pub user_instructions_path: Option<PathBuf>,
pub project_instructions_path: Option<PathBuf>,
}
let file = match tokio::fs::File::open(&candidate).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
Ok(f) => f,
};
/// Asynchronously collect the paths of the user instructions (from
/// `CODEX_HOME/AGENTS.md`) and the project instructions (nearest `AGENTS.md`
/// discovered by [`find_project_doc`]'s search algorithm).
///
/// - If `project_doc_max_bytes == 0` we consider project docs disabled and the
/// path will be `None` even if a file exists.
/// - Empty files are treated as "not set".
pub async fn collect_instructions_info(config: &Config) -> InstructionsInfo {
let user_instructions_path = find_user_instructions_path(&config.codex_home);
let size = file.metadata().await?.len();
let project_instructions_path = if config.project_doc_max_bytes == 0 {
None
} else {
find_project_doc_path_async(config).await
};
let reader = tokio::io::BufReader::new(file);
let mut data = Vec::with_capacity(std::cmp::min(size as usize, max_bytes));
let mut limited = reader.take(max_bytes as u64);
limited.read_to_end(&mut data).await?;
InstructionsInfo {
user_instructions_path,
project_instructions_path,
}
}
if size as usize > max_bytes {
tracing::warn!(
"Project doc `{}` exceeds {max_bytes} bytes - truncating.",
candidate.display(),
);
}
let contents = String::from_utf8_lossy(&data).to_string();
if contents.trim().is_empty() {
// Empty file treat as not found.
continue;
}
return Ok(Some(contents));
/// Internal helper that mirrors the search performed by [`find_project_doc`] but
/// returns the discovered file path instead of the contents.
async fn find_project_doc_path_async(config: &Config) -> Option<PathBuf> {
// Attempt in cwd first.
if let Some(p) = find_first_candidate_path_async(&config.cwd, CANDIDATE_FILENAMES).await {
return Some(p);
}
Ok(None)
// Walk up towards git root, then stop.
let mut dir = match config.cwd.canonicalize() {
Ok(c) => c,
Err(_) => config.cwd.clone(),
};
while let Some(parent) = dir.parent() {
let git_marker = dir.join(".git");
let git_exists = match tokio::fs::metadata(&git_marker).await {
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(_) => false,
};
if git_exists {
return find_first_candidate_path_async(&dir, CANDIDATE_FILENAMES).await;
}
dir = parent.to_path_buf();
}
None
}
async fn find_first_candidate_path_async(dir: &Path, names: &[&str]) -> Option<PathBuf> {
for name in names {
let candidate = dir.join(name);
match tokio::fs::read_to_string(&candidate).await {
Ok(s) if !s.trim().is_empty() => return Some(candidate),
_ => continue,
}
}
None
}
/// Synchronous variant for UI code that isn't async-aware. Mirrors
/// [`collect_instructions_info`] using blocking filesystem APIs.
pub fn collect_instructions_info_sync(config: &Config) -> InstructionsInfo {
let user_instructions_path = find_user_instructions_path(&config.codex_home);
let project_instructions_path = if config.project_doc_max_bytes == 0 {
None
} else {
find_project_doc_path_sync(config)
};
InstructionsInfo {
user_instructions_path,
project_instructions_path,
}
}
fn find_project_doc_path_sync(config: &Config) -> Option<PathBuf> {
// Try cwd first
if let Some(p) = find_first_candidate_path_sync(&config.cwd, CANDIDATE_FILENAMES) {
return Some(p);
}
let mut dir = config
.cwd
.canonicalize()
.unwrap_or_else(|_| config.cwd.clone());
while let Some(parent) = dir.parent() {
let git_marker = dir.join(".git");
let git_exists = match std::fs::metadata(&git_marker) {
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(_) => false,
};
if git_exists {
return find_first_candidate_path_sync(&dir, CANDIDATE_FILENAMES);
}
dir = parent.to_path_buf();
}
None
}
fn find_first_candidate_path_sync(dir: &Path, names: &[&str]) -> Option<PathBuf> {
for name in names {
let candidate = dir.join(name);
match std::fs::read_to_string(&candidate) {
Ok(s) if !s.trim().is_empty() => return Some(candidate),
_ => continue,
}
}
None
}
/// Read a file with a maximum byte limit; empty content returns None.
async fn read_file_with_limit(path: &Path, max_bytes: usize) -> std::io::Result<Option<String>> {
let file = match tokio::fs::File::open(path).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
Ok(f) => f,
};
let size = file.metadata().await?.len();
let reader = tokio::io::BufReader::new(file);
let mut data = Vec::with_capacity(std::cmp::min(size as usize, max_bytes));
let mut limited = reader.take(max_bytes as u64);
limited.read_to_end(&mut data).await?;
if size as usize > max_bytes {
tracing::warn!(
"Project doc `{}` exceeds {max_bytes} bytes - truncating.",
path.display(),
);
}
let contents = String::from_utf8_lossy(&data).to_string();
if contents.trim().is_empty() {
return Ok(None);
}
Ok(Some(contents))
}
#[cfg(test)]

View File

@@ -11,6 +11,7 @@ use codex_core::config::Config;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::project_doc::collect_instructions_info_sync;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::SandboxPolicy;
@@ -511,6 +512,31 @@ impl HistoryCell {
None => config.cwd.display().to_string(),
};
lines.push(Line::from(vec![" • Path: ".into(), cwd_str.into()]));
// If instructions are configured, show the source paths at the top.
let info = collect_instructions_info_sync(config);
if info.user_instructions_path.is_some() || info.project_instructions_path.is_some() {
let render_path = |p: &std::path::PathBuf| -> String {
match relativize_to_home(p) {
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
_ => p.display().to_string(),
}
};
let mut parts: Vec<String> = Vec::new();
if let Some(p) = info.user_instructions_path.as_ref() {
parts.push(render_path(p));
}
if let Some(p) = info.project_instructions_path.as_ref() {
parts.push(render_path(p));
}
let joined = if parts.len() == 2 {
format!("{} + {}", parts[0], parts[1])
} else {
parts.join("")
};
lines.push(Line::from(vec![" • AGENTS.md: ".into(), joined.into()]));
}
// Approval mode (as-is)
lines.push(Line::from(vec![
" • Approval Mode: ".into(),