feat(auto-drive): include AUTO_AGENTS guidance

This commit is contained in:
James Peter
2025-09-30 16:37:56 +10:00
parent 2767916b34
commit 2029365db4
2 changed files with 110 additions and 13 deletions
+68 -7
View File
@@ -1,6 +1,7 @@
//! Project-level documentation discovery.
//!
//! Project-level documentation can be stored in files named `AGENTS.md`.
//! Project-level documentation can be stored in files named `AGENTS.md` and
//! Auto Drive can use specialised guidance stored in `AUTO_AGENTS.md`.
//! We include the concatenation of all files found along the path from the
//! repository root to the current working directory as follows:
//!
@@ -17,8 +18,11 @@ use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use tracing::error;
/// Currently, we only match the filename `AGENTS.md` exactly.
const CANDIDATE_FILENAMES: &[&str] = &["AGENTS.md"];
/// Filenames recognised for standard agent instructions.
const AGENT_FILENAMES: &[&str] = &["AGENTS.md"];
/// Filenames recognised for Auto Drive instructions.
const AUTO_AGENT_FILENAMES: &[&str] = &["AUTO_AGENTS.md"];
/// When both `Config::instructions` and the project doc are present, they will
/// be concatenated with the following separator.
@@ -48,14 +52,17 @@ pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
/// concatenation of all discovered docs. If no documentation file is found the
/// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so
/// callers can decide how to handle them.
pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String>> {
async fn read_project_docs_with_candidates(
config: &Config,
candidate_filenames: &[&str],
) -> std::io::Result<Option<String>> {
let max_total = config.project_doc_max_bytes;
if max_total == 0 {
return Ok(None);
}
let paths = discover_project_doc_paths(config)?;
let paths = discover_project_doc_paths_with_candidates(config, candidate_filenames)?;
if paths.is_empty() {
return Ok(None);
}
@@ -101,12 +108,23 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String
}
}
pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String>> {
read_project_docs_with_candidates(config, AGENT_FILENAMES).await
}
pub async fn read_auto_drive_docs(config: &Config) -> std::io::Result<Option<String>> {
read_project_docs_with_candidates(config, AUTO_AGENT_FILENAMES).await
}
/// Discover the list of AGENTS.md files using the same search rules as
/// `read_project_docs`, but return the file paths instead of concatenated
/// contents. The list is ordered from repository root to the current working
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
/// is zero, returns an empty list.
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
fn discover_project_doc_paths_with_candidates(
config: &Config,
candidate_filenames: &[&str],
) -> std::io::Result<Vec<PathBuf>> {
let mut dir = config.cwd.clone();
if let Ok(canon) = dir.canonicalize() {
dir = canon;
@@ -153,7 +171,7 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
let mut found: Vec<PathBuf> = Vec::new();
for d in search_dirs {
for name in CANDIDATE_FILENAMES {
for name in candidate_filenames {
let candidate = d.join(name);
match std::fs::symlink_metadata(&candidate) {
Ok(md) => {
@@ -173,6 +191,14 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
Ok(found)
}
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
discover_project_doc_paths_with_candidates(config, AGENT_FILENAMES)
}
pub fn discover_auto_drive_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
discover_project_doc_paths_with_candidates(config, AUTO_AGENT_FILENAMES)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -347,4 +373,39 @@ mod tests {
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "root doc\n\ncrate doc");
}
#[tokio::test]
async fn auto_drive_doc_missing_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg = make_config(&tmp, 4096, None);
let res = read_auto_drive_docs(&cfg).await;
assert!(res.expect("auto docs read").is_none());
}
#[tokio::test]
async fn auto_drive_docs_follow_same_lookup_rules() {
let repo = tempfile::tempdir().expect("tempdir");
std::fs::write(
repo.path().join(".git"),
"gitdir: /path/to/actual/git/dir\n",
)
.unwrap();
fs::write(repo.path().join("AUTO_AGENTS.md"), "root auto").unwrap();
let nested = repo.path().join("workspace/crate_a");
std::fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("AUTO_AGENTS.md"), "nested auto").unwrap();
let mut cfg = make_config(&repo, 4096, None);
cfg.cwd = nested;
let res = read_auto_drive_docs(&cfg)
.await
.expect("auto doc expected")
.expect("auto doc should be present");
assert_eq!(res, "root auto\n\nnested auto");
}
}
@@ -8,6 +8,7 @@ use codex_core::config::Config;
use codex_core::config_types::ReasoningEffort;
use codex_core::debug_logger::DebugLogger;
use codex_core::model_family::{derive_default_model_family, find_family_for_model};
use codex_core::project_doc::read_auto_drive_docs;
use codex_core::protocol::SandboxPolicy;
use codex_core::{AuthManager, ModelClient, Prompt, ResponseEvent, TextFormat};
use codex_core::error::CodexErr;
@@ -17,7 +18,7 @@ use reqwest::StatusCode;
use serde::Deserialize;
use serde_json::{self, json, Value};
use tokio_util::sync::CancellationToken;
use tracing::debug;
use tracing::{debug, warn};
use uuid::Uuid;
use crate::app_event::{AppEvent, AutoCoordinatorStatus};
@@ -167,6 +168,27 @@ fn run_auto_loop(
)),
);
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("creating runtime for auto coordinator")?;
let auto_instructions = match runtime.block_on(read_auto_drive_docs(config.as_ref())) {
Ok(Some(text)) => {
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
Ok(None) => None,
Err(err) => {
warn!("failed to read AUTO_AGENTS.md instructions: {err:#}");
None
}
};
let sandbox_label = if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
"full access"
} else {
@@ -179,11 +201,6 @@ fn run_auto_loop(
let platform = std::env::consts::OS;
debug!("[Auto coordinator] starting: goal={goal_text} platform={platform}");
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("creating runtime for auto coordinator")?;
let mut pending_conversation = Some(initial_conversation);
let mut stopped = false;
let mut requests_completed: u64 = 0;
@@ -224,6 +241,7 @@ fn run_auto_loop(
&primary_goal_message,
&schema,
conv,
auto_instructions.as_deref(),
&app_event_tx,
&cancel_token,
) {
@@ -416,6 +434,7 @@ fn request_coordinator_decision(
primary_goal: &str,
schema: &Value,
conversation: Vec<ResponseItem>,
auto_instructions: Option<&str>,
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
) -> Result<(AutoCoordinatorStatus, String, Option<String>)> {
@@ -426,6 +445,7 @@ fn request_coordinator_decision(
primary_goal,
schema,
&conversation,
auto_instructions,
app_event_tx,
cancel_token,
)?;
@@ -465,6 +485,7 @@ fn request_decision(
primary_goal: &str,
schema: &Value,
conversation: &[ResponseItem],
auto_instructions: Option<&str>,
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
) -> Result<String> {
@@ -475,6 +496,7 @@ fn request_decision(
primary_goal,
schema,
conversation,
auto_instructions,
app_event_tx,
cancel_token,
MODEL_SLUG,
@@ -496,6 +518,7 @@ fn request_decision(
primary_goal,
schema,
conversation,
auto_instructions,
app_event_tx,
cancel_token,
&fallback_slug,
@@ -519,6 +542,7 @@ fn request_decision_with_model(
primary_goal: &str,
schema: &Value,
conversation: &[ResponseItem],
auto_instructions: Option<&str>,
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
model_slug: &str,
@@ -527,6 +551,7 @@ fn request_decision_with_model(
let primary_goal = primary_goal.to_string();
let schema = schema.clone();
let conversation: Vec<ResponseItem> = conversation.to_vec();
let auto_instructions = auto_instructions.map(|text| text.to_string());
let tx = app_event_tx.clone();
let cancel = cancel_token.clone();
let classify = |error: &anyhow::Error| classify_model_error(error);
@@ -535,12 +560,14 @@ fn request_decision_with_model(
let result = runtime.block_on(async move {
retry_with_backoff(
|| {
let instructions = auto_instructions.clone();
let prompt = build_prompt_for_model(
&developer_intro,
&primary_goal,
&schema,
&conversation,
model_slug,
instructions.as_deref(),
);
let tx_inner = tx.clone();
async move {
@@ -651,9 +678,18 @@ fn build_prompt_for_model(
schema: &Value,
conversation: &[ResponseItem],
model_slug: &str,
auto_instructions: Option<&str>,
) -> Prompt {
let mut prompt = Prompt::default();
prompt.store = true;
if let Some(instructions) = auto_instructions {
let trimmed = instructions.trim();
if !trimmed.is_empty() {
prompt
.input
.push(make_message("developer", trimmed.to_string()));
}
}
prompt
.input
.push(make_message("developer", developer_intro.to_string()));