Codex Handbook
core/src/tools/handlers/agent_jobs.rs 762 lines
use crate::agent::control::SpawnAgentOptions;use crate::agent::status::is_final;use crate::config::Config;use crate::function_tool::FunctionCallError;use crate::session::session::Session;use crate::session::turn_context::TurnContext;use crate::tools::handlers::multi_agents::build_agent_spawn_config;use crate::tools::handlers::parse_arguments;use codex_protocol::ThreadId;use codex_protocol::error::CodexErr;use codex_protocol::protocol::AgentStatus;use codex_protocol::protocol::MultiAgentVersion;use codex_protocol::protocol::SessionSource;use codex_protocol::protocol::SubAgentSource;use codex_protocol::user_input::UserInput;use codex_utils_absolute_path::AbsolutePathBuf;use futures::StreamExt;use futures::stream::FuturesUnordered;use serde::Deserialize;use serde::Serialize;use serde_json::Value;use std::collections::HashMap;use std::collections::HashSet;use std::path::PathBuf;use std::sync::Arc;use tokio::sync::watch::Receiver;use tokio::time::Duration;use tokio::time::Instant;use tokio::time::timeout;use uuid::Uuid;mod report_agent_job_result;mod spawn_agents_on_csv;pub use report_agent_job_result::ReportAgentJobResultHandler;pub use spawn_agents_on_csv::SpawnAgentsOnCsvHandler;const DEFAULT_AGENT_JOB_CONCURRENCY: usize = 16;const MAX_AGENT_JOB_CONCURRENCY: usize = 64;const STATUS_POLL_INTERVAL: Duration = Duration::from_millis(250);const DEFAULT_AGENT_JOB_ITEM_TIMEOUT: Duration = Duration::from_secs(60 * 30);#[derive(Debug, Deserialize)]struct SpawnAgentsOnCsvArgs {    csv_path: String,    instruction: String,    id_column: Option<String>,    output_csv_path: Option<String>,    output_schema: Option<Value>,    max_concurrency: Option<usize>,    max_workers: Option<usize>,    max_runtime_seconds: Option<u64>,}#[derive(Debug, Deserialize)]struct ReportAgentJobResultArgs {    job_id: String,    item_id: String,    result: Value,    stop: Option<bool>,}#[derive(Debug, Serialize)]struct SpawnAgentsOnCsvResult {    job_id: String,    status: String,    output_csv_path: String,    total_items: usize,    completed_items: usize,    failed_items: usize,    job_error: Option<String>,    failed_item_errors: Option<Vec<AgentJobFailureSummary>>,}#[derive(Debug, Serialize)]struct AgentJobFailureSummary {    item_id: String,    source_id: Option<String>,    last_error: String,}#[derive(Debug, Serialize)]struct ReportAgentJobResultToolResult {    accepted: bool,}#[derive(Debug, Clone)]struct JobRunnerOptions {    max_concurrency: usize,    spawn_config: Config,}#[derive(Debug, Clone)]struct ActiveJobItem {    item_id: String,    started_at: Instant,    status_rx: Option<Receiver<AgentStatus>>,}fn required_state_db(    session: &Arc<Session>,) -> Result<Arc<codex_state::StateRuntime>, FunctionCallError> {    session.state_db().ok_or_else(|| {        FunctionCallError::Fatal("sqlite state db is unavailable for this session".to_string())    })}async fn build_runner_options(    session: &Arc<Session>,    turn: &Arc<TurnContext>,    requested_concurrency: Option<usize>,) -> Result<JobRunnerOptions, FunctionCallError> {    let multi_agent_version = turn.multi_agent_version;    if multi_agent_version == MultiAgentVersion::Disabled {        return Err(FunctionCallError::RespondToModel(            "multi-agent runtime is disabled; this session cannot spawn workers".to_string(),        ));    }    let agent_max_threads = turn.config.effective_agent_max_threads(multi_agent_version);    if agent_max_threads == Some(0) {        return Err(FunctionCallError::RespondToModel(            "agent thread limit reached; this session cannot spawn more subagents".to_string(),        ));    }    let max_concurrency = normalize_concurrency(requested_concurrency, agent_max_threads);    let base_instructions = session.get_base_instructions().await;    let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?;    Ok(JobRunnerOptions {        max_concurrency,        spawn_config,    })}fn normalize_concurrency(requested: Option<usize>, max_threads: Option<usize>) -> usize {    let requested = requested.unwrap_or(DEFAULT_AGENT_JOB_CONCURRENCY).max(1);    let requested = requested.min(MAX_AGENT_JOB_CONCURRENCY);    if let Some(max_threads) = max_threads {        requested.min(max_threads.max(1))    } else {        requested    }}fn normalize_max_runtime_seconds(requested: Option<u64>) -> Result<Option<u64>, FunctionCallError> {    let Some(requested) = requested else {        return Ok(None);    };    if requested == 0 {        return Err(FunctionCallError::RespondToModel(            "max_runtime_seconds must be >= 1".to_string(),        ));    }    Ok(Some(requested))}async fn run_agent_job_loop(    session: Arc<Session>,    turn: Arc<TurnContext>,    db: Arc<codex_state::StateRuntime>,    job_id: String,    options: JobRunnerOptions,) -> anyhow::Result<()> {    let job = db        .get_agent_job(job_id.as_str())        .await?        .ok_or_else(|| anyhow::anyhow!("agent job {job_id} was not found"))?;    let runtime_timeout = job_runtime_timeout(&job);    let mut active_items: HashMap<ThreadId, ActiveJobItem> = HashMap::new();    recover_running_items(        session.clone(),        db.clone(),        job_id.as_str(),        &mut active_items,        runtime_timeout,    )    .await?;    let mut cancel_requested = db.is_agent_job_cancelled(job_id.as_str()).await?;    loop {        let mut progressed = false;        if !cancel_requested && db.is_agent_job_cancelled(job_id.as_str()).await? {            cancel_requested = true;        }        if !cancel_requested && active_items.len() < options.max_concurrency {            let slots = options.max_concurrency - active_items.len();            let pending_items = db                .list_agent_job_items(                    job_id.as_str(),                    Some(codex_state::AgentJobItemStatus::Pending),                    Some(slots),                )                .await?;            for item in pending_items {                let prompt = build_worker_prompt(&job, &item)?;                let items = vec![UserInput::Text {                    text: prompt,                    text_elements: Vec::new(),                }];                let thread_id = match session                    .services                    .agent_control                    .spawn_agent_with_metadata(                        options.spawn_config.clone(),                        items.into(),                        Some(SessionSource::SubAgent(SubAgentSource::Other(format!(                            "agent_job:{job_id}"                        )))),                        SpawnAgentOptions {                            parent_thread_id: Some(session.thread_id),                            environments: Some(turn.environments.to_selections()),                            ..Default::default()                        },                    )                    .await                {                    Ok(spawned_agent) => spawned_agent.thread_id,                    Err(CodexErr::AgentLimitReached { .. }) => {                        db.mark_agent_job_item_pending(                            job_id.as_str(),                            item.item_id.as_str(),                            /*error_message*/ None,                        )                        .await?;                        break;                    }                    Err(err) => {                        let error_message = format!("failed to spawn worker: {err}");                        db.mark_agent_job_item_failed(                            job_id.as_str(),                            item.item_id.as_str(),                            error_message.as_str(),                        )                        .await?;                        progressed = true;                        continue;                    }                };                let assigned = db                    .mark_agent_job_item_running_with_thread(                        job_id.as_str(),                        item.item_id.as_str(),                        thread_id.to_string().as_str(),                    )                    .await?;                if !assigned {                    let _ = session                        .services                        .agent_control                        .shutdown_live_agent(thread_id)                        .await;                    continue;                }                active_items.insert(                    thread_id,                    ActiveJobItem {                        item_id: item.item_id.clone(),                        started_at: Instant::now(),                        status_rx: session                            .services                            .agent_control                            .subscribe_status(thread_id)                            .await                            .ok(),                    },                );                progressed = true;            }        }        if reap_stale_active_items(            session.clone(),            db.clone(),            job_id.as_str(),            &mut active_items,            runtime_timeout,        )        .await?        {            progressed = true;        }        let finished = find_finished_threads(session.clone(), &active_items).await;        if finished.is_empty() {            let progress = db.get_agent_job_progress(job_id.as_str()).await?;            if cancel_requested {                if progress.running_items == 0 && active_items.is_empty() {                    break;                }            } else if progress.pending_items == 0                && progress.running_items == 0                && active_items.is_empty()            {                break;            }            if !progressed {                wait_for_status_change(&active_items).await;            }            continue;        }        for (thread_id, item_id) in finished {            finalize_finished_item(                session.clone(),                db.clone(),                job_id.as_str(),                item_id.as_str(),                thread_id,            )            .await?;            active_items.remove(&thread_id);        }    }    if let Err(err) = export_job_csv_snapshot(db.clone(), &job).await {        let message = format!("auto-export failed: {err}");        db.mark_agent_job_failed(job_id.as_str(), message.as_str())            .await?;        return Ok(());    }    let cancelled = cancel_requested || db.is_agent_job_cancelled(job_id.as_str()).await?;    if cancelled {        return Ok(());    }    db.mark_agent_job_completed(job_id.as_str()).await?;    Ok(())}async fn export_job_csv_snapshot(    db: Arc<codex_state::StateRuntime>,    job: &codex_state::AgentJob,) -> anyhow::Result<()> {    let items = db        .list_agent_job_items(job.id.as_str(), /*status*/ None, /*limit*/ None)        .await?;    let csv_content = render_job_csv(job.input_headers.as_slice(), items.as_slice())        .map_err(|err| anyhow::anyhow!("failed to render job csv for auto-export: {err}"))?;    let output_path = PathBuf::from(job.output_csv_path.clone());    if let Some(parent) = output_path.parent() {        tokio::fs::create_dir_all(parent).await?;    }    tokio::fs::write(&output_path, csv_content).await?;    Ok(())}async fn recover_running_items(    session: Arc<Session>,    db: Arc<codex_state::StateRuntime>,    job_id: &str,    active_items: &mut HashMap<ThreadId, ActiveJobItem>,    runtime_timeout: Duration,) -> anyhow::Result<()> {    let running_items = db        .list_agent_job_items(            job_id,            Some(codex_state::AgentJobItemStatus::Running),            /*limit*/ None,        )        .await?;    for item in running_items {        if is_item_stale(&item, runtime_timeout) {            let error_message = format!("worker exceeded max runtime of {runtime_timeout:?}");            db.mark_agent_job_item_failed(job_id, item.item_id.as_str(), error_message.as_str())                .await?;            if let Some(assigned_thread_id) = item.assigned_thread_id.as_ref()                && let Ok(thread_id) = ThreadId::from_string(assigned_thread_id.as_str())            {                let _ = session                    .services                    .agent_control                    .shutdown_live_agent(thread_id)                    .await;            }            continue;        }        let Some(assigned_thread_id) = item.assigned_thread_id.clone() else {            db.mark_agent_job_item_failed(                job_id,                item.item_id.as_str(),                "running item is missing assigned_thread_id",            )            .await?;            continue;        };        let thread_id = match ThreadId::from_string(assigned_thread_id.as_str()) {            Ok(thread_id) => thread_id,            Err(err) => {                let error_message = format!("invalid assigned_thread_id: {err:?}");                db.mark_agent_job_item_failed(                    job_id,                    item.item_id.as_str(),                    error_message.as_str(),                )                .await?;                continue;            }        };        if is_final(&session.services.agent_control.get_status(thread_id).await) {            finalize_finished_item(                session.clone(),                db.clone(),                job_id,                item.item_id.as_str(),                thread_id,            )            .await?;        } else {            active_items.insert(                thread_id,                ActiveJobItem {                    item_id: item.item_id.clone(),                    started_at: started_at_from_item(&item),                    status_rx: session                        .services                        .agent_control                        .subscribe_status(thread_id)                        .await                        .ok(),                },            );        }    }    Ok(())}async fn find_finished_threads(    session: Arc<Session>,    active_items: &HashMap<ThreadId, ActiveJobItem>,) -> Vec<(ThreadId, String)> {    let mut finished = Vec::new();    for (thread_id, item) in active_items {        let status = active_item_status(session.as_ref(), *thread_id, item).await;        if is_final(&status) {            finished.push((*thread_id, item.item_id.clone()));        }    }    finished}async fn active_item_status(    session: &Session,    thread_id: ThreadId,    item: &ActiveJobItem,) -> AgentStatus {    if let Some(status_rx) = item.status_rx.as_ref()        && status_rx.has_changed().is_ok()    {        return status_rx.borrow().clone();    }    session.services.agent_control.get_status(thread_id).await}async fn wait_for_status_change(active_items: &HashMap<ThreadId, ActiveJobItem>) {    let mut waiters = FuturesUnordered::new();    for item in active_items.values() {        if let Some(status_rx) = item.status_rx.as_ref() {            let mut status_rx = status_rx.clone();            waiters.push(async move {                let _ = status_rx.changed().await;            });        }    }    if waiters.is_empty() {        tokio::time::sleep(STATUS_POLL_INTERVAL).await;        return;    }    let _ = timeout(STATUS_POLL_INTERVAL, waiters.next()).await;}async fn reap_stale_active_items(    session: Arc<Session>,    db: Arc<codex_state::StateRuntime>,    job_id: &str,    active_items: &mut HashMap<ThreadId, ActiveJobItem>,    runtime_timeout: Duration,) -> anyhow::Result<bool> {    let mut stale = Vec::new();    for (thread_id, item) in active_items.iter() {        if item.started_at.elapsed() >= runtime_timeout {            stale.push((*thread_id, item.item_id.clone()));        }    }    if stale.is_empty() {        return Ok(false);    }    for (thread_id, item_id) in stale {        let error_message = format!("worker exceeded max runtime of {runtime_timeout:?}");        db.mark_agent_job_item_failed(job_id, item_id.as_str(), error_message.as_str())            .await?;        let _ = session            .services            .agent_control            .shutdown_live_agent(thread_id)            .await;        active_items.remove(&thread_id);    }    Ok(true)}async fn finalize_finished_item(    session: Arc<Session>,    db: Arc<codex_state::StateRuntime>,    job_id: &str,    item_id: &str,    thread_id: ThreadId,) -> anyhow::Result<()> {    let item = db        .get_agent_job_item(job_id, item_id)        .await?        .ok_or_else(|| {            anyhow::anyhow!("job item not found for finalization: {job_id}/{item_id}")        })?;    if matches!(item.status, codex_state::AgentJobItemStatus::Running) {        if item.result_json.is_some() {            let _ = db.mark_agent_job_item_completed(job_id, item_id).await?;        } else {            let _ = db                .mark_agent_job_item_failed(                    job_id,                    item_id,                    "worker finished without calling report_agent_job_result",                )                .await?;        }    }    let _ = session        .services        .agent_control        .shutdown_live_agent(thread_id)        .await;    Ok(())}fn build_worker_prompt(    job: &codex_state::AgentJob,    item: &codex_state::AgentJobItem,) -> anyhow::Result<String> {    let job_id = job.id.as_str();    let item_id = item.item_id.as_str();    let instruction = render_instruction_template(job.instruction.as_str(), &item.row_json);    let output_schema = job        .output_schema_json        .as_ref()        .map(serde_json::to_string_pretty)        .transpose()?        .unwrap_or_else(|| "{}".to_string());    let row_json = serde_json::to_string_pretty(&item.row_json)?;    Ok(format!(        "You are processing one item for a generic agent job.\n\Job ID: {job_id}\n\Item ID: {item_id}\n\n\Task instruction:\n\{instruction}\n\n\Input row (JSON):\n\{row_json}\n\n\Expected result schema (JSON Schema or {{}}):\n\{output_schema}\n\n\You MUST call the `report_agent_job_result` tool exactly once with:\n\1. `job_id` = \"{job_id}\"\n\2. `item_id` = \"{item_id}\"\n\3. `result` = a JSON object that contains your analysis result for this row.\n\n\If you need to stop the job early, include `stop` = true in the tool call.\n\n\After the tool call succeeds, stop.",    ))}fn render_instruction_template(instruction: &str, row_json: &Value) -> String {    const OPEN_BRACE_SENTINEL: &str = "__CODEX_OPEN_BRACE__";    const CLOSE_BRACE_SENTINEL: &str = "__CODEX_CLOSE_BRACE__";    let mut rendered = instruction        .replace("{{", OPEN_BRACE_SENTINEL)        .replace("}}", CLOSE_BRACE_SENTINEL);    let Some(row) = row_json.as_object() else {        return rendered            .replace(OPEN_BRACE_SENTINEL, "{")            .replace(CLOSE_BRACE_SENTINEL, "}");    };    for (key, value) in row {        let placeholder = format!("{{{key}}}");        let replacement = value            .as_str()            .map(str::to_string)            .unwrap_or_else(|| value.to_string());        rendered = rendered.replace(placeholder.as_str(), replacement.as_str());    }    rendered        .replace(OPEN_BRACE_SENTINEL, "{")        .replace(CLOSE_BRACE_SENTINEL, "}")}fn ensure_unique_headers(headers: &[String]) -> Result<(), FunctionCallError> {    let mut seen = HashSet::new();    for header in headers {        if !seen.insert(header) {            return Err(FunctionCallError::RespondToModel(format!(                "csv header {header} is duplicated"            )));        }    }    Ok(())}fn job_runtime_timeout(job: &codex_state::AgentJob) -> Duration {    job.max_runtime_seconds        .map(Duration::from_secs)        .unwrap_or(DEFAULT_AGENT_JOB_ITEM_TIMEOUT)}fn started_at_from_item(item: &codex_state::AgentJobItem) -> Instant {    let now = chrono::Utc::now();    let age = now.signed_duration_since(item.updated_at);    if let Ok(age) = age.to_std() {        Instant::now().checked_sub(age).unwrap_or_else(Instant::now)    } else {        Instant::now()    }}fn is_item_stale(item: &codex_state::AgentJobItem, runtime_timeout: Duration) -> bool {    let now = chrono::Utc::now();    if let Ok(age) = now.signed_duration_since(item.updated_at).to_std() {        age >= runtime_timeout    } else {        false    }}fn default_output_csv_path(input_csv_path: &AbsolutePathBuf, job_id: &str) -> AbsolutePathBuf {    let stem = input_csv_path        .as_path()        .file_stem()        .and_then(|stem| stem.to_str())        .unwrap_or("agent_job_output");    let job_suffix = &job_id[..8];    let output_dir = input_csv_path        .parent()        .unwrap_or_else(|| input_csv_path.clone());    output_dir.join(format!("{stem}.agent-job-{job_suffix}.csv"))}fn parse_csv(content: &str) -> Result<(Vec<String>, Vec<Vec<String>>), String> {    let mut reader = csv::ReaderBuilder::new()        .has_headers(true)        .flexible(true)        .from_reader(content.as_bytes());    let headers_record = reader.headers().map_err(|err| err.to_string())?;    let mut headers: Vec<String> = headers_record.iter().map(str::to_string).collect();    if let Some(first) = headers.first_mut() {        *first = first.trim_start_matches('\u{feff}').to_string();    }    let mut rows = Vec::new();    for record in reader.records() {        let record = record.map_err(|err| err.to_string())?;        let row: Vec<String> = record.iter().map(str::to_string).collect();        if row.iter().all(std::string::String::is_empty) {            continue;        }        rows.push(row);    }    Ok((headers, rows))}fn render_job_csv(    headers: &[String],    items: &[codex_state::AgentJobItem],) -> Result<String, FunctionCallError> {    let mut csv = String::new();    let mut output_headers = headers.to_vec();    output_headers.extend([        "job_id".to_string(),        "item_id".to_string(),        "row_index".to_string(),        "source_id".to_string(),        "status".to_string(),        "attempt_count".to_string(),        "last_error".to_string(),        "result_json".to_string(),        "reported_at".to_string(),        "completed_at".to_string(),    ]);    csv.push_str(        output_headers            .iter()            .map(|header| csv_escape(header.as_str()))            .collect::<Vec<_>>()            .join(",")            .as_str(),    );    csv.push('\n');    for item in items {        let row_object = item.row_json.as_object().ok_or_else(|| {            let item_id = item.item_id.as_str();            FunctionCallError::RespondToModel(format!(                "row_json for item {item_id} is not a JSON object"            ))        })?;        let mut row_values = Vec::new();        for header in headers {            let value = row_object                .get(header)                .map_or_else(String::new, value_to_csv_string);            row_values.push(csv_escape(value.as_str()));        }        row_values.push(csv_escape(item.job_id.as_str()));        row_values.push(csv_escape(item.item_id.as_str()));        row_values.push(csv_escape(item.row_index.to_string().as_str()));        row_values.push(csv_escape(            item.source_id.clone().unwrap_or_default().as_str(),        ));        row_values.push(csv_escape(item.status.as_str()));        row_values.push(csv_escape(item.attempt_count.to_string().as_str()));        row_values.push(csv_escape(            item.last_error.clone().unwrap_or_default().as_str(),        ));        row_values.push(csv_escape(            item.result_json                .as_ref()                .map_or_else(String::new, std::string::ToString::to_string)                .as_str(),        ));        row_values.push(csv_escape(            item.reported_at                .map(|value| value.to_rfc3339())                .unwrap_or_default()                .as_str(),        ));        row_values.push(csv_escape(            item.completed_at                .map(|value| value.to_rfc3339())                .unwrap_or_default()                .as_str(),        ));        csv.push_str(row_values.join(",").as_str());        csv.push('\n');    }    Ok(csv)}fn value_to_csv_string(value: &Value) -> String {    match value {        Value::Null => String::new(),        Value::String(s) => s.clone(),        Value::Bool(b) => b.to_string(),        Value::Number(n) => n.to_string(),        Value::Array(_) | Value::Object(_) => value.to_string(),    }}fn csv_escape(value: &str) -> String {    if value.contains(',') || value.contains('\n') || value.contains('\r') || value.contains('"') {        let escaped = value.replace('"', "\"\"");        format!("\"{escaped}\"")    } else {        value.to_string()    }}#[cfg(test)]#[path = "agent_jobs_tests.rs"]mod tests;