mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
16 Commits
exec-run-a
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec51479de | ||
|
|
b3130e324d | ||
|
|
5bfd4242b8 | ||
|
|
4697623888 | ||
|
|
159510fa38 | ||
|
|
5f41b6040f | ||
|
|
a4aeba1282 | ||
|
|
4d0dc29f3e | ||
|
|
39f47f2733 | ||
|
|
7b94347761 | ||
|
|
d4db71466f | ||
|
|
ca4cfbe9ef | ||
|
|
c974655475 | ||
|
|
b75fb20be4 | ||
|
|
773ab49050 | ||
|
|
72f54926ad |
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user