feat(agents/cloud): add submit --wait and HUD progress; treat builtin

- Add cloud submit non-interactive path with optional --wait
- Poll remote status and print final results (messages + diff)
- Stream stderr progress into Agents HUD; cap very large outputs (~500KB)
- Treat 'cloud' as a built-in agent (no external binary needed)
- Wire core agent_run to invoke current executable with cloud submit --wait

Validated via ./build-fast.sh (no warnings).
This commit is contained in:
James Peter
2025-10-03 08:40:15 +10:00
parent 34664b8827
commit 51e60cbc12
4 changed files with 302 additions and 15 deletions
+36
View File
@@ -1,9 +1,45 @@
use clap::Parser;
use codex_common::CliConfigOverrides;
#[derive(Debug, Clone, clap::Subcommand)]
pub enum Command {
/// Submit a new task non-interactively and print the created id
Submit(SubmitArgs),
}
#[derive(Parser, Debug, Default, Clone)]
pub struct SubmitArgs {
/// The task prompt to submit to Codex Cloud
#[arg(value_name = "PROMPT")]
pub prompt: String,
/// Optional environment id (falls back to auto-detect when omitted)
#[arg(long = "env", value_name = "ENV_ID")]
pub env: Option<String>,
/// Best-of-N attempts for the assistant (default: 1)
#[arg(long = "best-of", default_value_t = 1)]
pub best_of: usize,
/// Enable QA/review mode when creating the task
#[arg(long = "qa", default_value_t = false)]
pub qa: bool,
/// Git ref to associate with the task (default: main)
#[arg(long = "git-ref", default_value = "main")]
pub git_ref: String,
/// Wait for completion and print final results
#[arg(long = "wait", default_value_t = false)]
pub wait: bool,
}
#[derive(Parser, Debug, Default)]
#[command(version)]
pub struct Cli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
#[clap(subcommand)]
pub cmd: Option<Command>,
}
+159 -1
View File
@@ -149,7 +149,12 @@ fn spawn_apply(
// (no standalone patch summarizer needed – UI displays raw diffs)
/// Entry point for the `codex cloud` subcommand.
pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
// Non-interactive submit mode: used by the core agent runner to create a
// cloud task and return its id on stdout.
if let Some(crate::cli::Command::Submit(args)) = cli.cmd {
return run_submit(args).await;
}
// Very minimal logging setup; mirrors other crates' pattern.
let default_level = "error";
let _ = tracing_subscriber::fmt()
@@ -1489,6 +1494,159 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
Ok(())
}
// Lightweight non-interactive submit implementation. Accepts a prompt and
// optional env/best-of/qa/git-ref and prints only the created task id.
async fn run_submit(args: crate::cli::SubmitArgs) -> anyhow::Result<()> {
set_user_agent_suffix("codex_cloud_tasks_submit");
let use_mock = matches!(
std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(),
Some("mock") | Some("MOCK")
);
let backend: Arc<dyn codex_cloud_tasks_client::CloudBackend> = if use_mock {
Arc::new(codex_cloud_tasks_client::MockClient)
} else {
let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
let ua = codex_core::default_client::get_codex_user_agent(None);
let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone())?
.with_user_agent(ua);
// Attach ChatGPT auth (required in production)
let _token = match codex_core::config::find_codex_home()
.ok()
.map(|home| {
codex_login::AuthManager::new(
home,
codex_login::AuthMode::ChatGPT,
codex_core::default_client::DEFAULT_ORIGINATOR.to_string(),
)
})
.and_then(|am| am.auth())
{
Some(auth) => match auth.get_token().await {
Ok(t) if !t.is_empty() => {
http = http.with_bearer_token(t.clone());
if let Some(acc) = auth
.get_account_id()
.or_else(|| util::extract_chatgpt_account_id(&t))
{
http = http.with_chatgpt_account_id(acc);
}
t
}
_ => {
eprintln!("Not signed in. Run 'codex login' and retry.");
std::process::exit(1);
}
},
None => {
eprintln!("Not signed in. Run 'codex login' and retry.");
std::process::exit(1);
}
};
Arc::new(http)
};
// Resolve target environment id
let env_id = if let Some(e) = args.env.clone() { e } else {
let base_url = util::normalize_base_url(
&std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()),
);
let headers = util::build_chatgpt_headers().await;
match crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await {
Ok(sel) => sel.id,
Err(_) => {
eprintln!("Failed to auto-detect environment. Provide --env <ENV_ID>.");
std::process::exit(1);
}
}
};
// Create the task
let created = codex_cloud_tasks_client::CloudBackend::create_task(
&*backend,
&env_id,
&args.prompt,
&args.git_ref,
args.qa,
args.best_of,
)
.await
.map_err(|e| anyhow::anyhow!("create_task failed: {e}"))?;
// If not waiting, print id and exit quickly
if !args.wait {
println!("{}", created.id.0);
return Ok(());
}
// Poll for completion and output a friendly summary when done
use tokio::time::{sleep, Duration};
eprintln!("Created task {}; waiting for completion…", created.id.0);
let task_id = created.id;
let mut seen_msgs = 0usize;
loop {
let text = codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, task_id.clone())
.await
.unwrap_or_default();
// Emit progress hints to stderr so stdout remains the final result only
if text.messages.len() > seen_msgs {
let new = text.messages.len() - seen_msgs;
seen_msgs = text.messages.len();
eprintln!("progress: +{} message(s)", new);
}
use codex_cloud_tasks_client::AttemptStatus as S;
match text.attempt_status {
S::Completed => {
// Try to get a diff snapshot if available
let diff_opt = codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task_id.clone())
.await
.ok()
.flatten();
// Build final output as plain text for the agent result
let mut out = String::new();
out.push_str(&format!("Cloud task completed: {}\n\n", task_id.0));
if let Some(p) = text.prompt.as_deref() {
out.push_str(&format!("Prompt:\n{}\n\n", p.trim()));
}
if !text.messages.is_empty() {
out.push_str("Assistant Messages:\n");
for (i, m) in text.messages.iter().enumerate() {
out.push_str(&format!("{}. ", i + 1));
out.push_str(m.trim());
out.push_str("\n\n");
}
}
match diff_opt {
Some(diff) => {
out.push_str("Diff:\n");
out.push_str(&diff);
out.push_str("\n");
}
None => out.push_str("No diff available.\n"),
}
print!("{}", out);
return Ok(());
}
S::Failed => {
anyhow::bail!("Task failed: {}", task_id.0);
}
S::Cancelled => {
anyhow::bail!("Task cancelled: {}", task_id.0);
}
_ => {
sleep(Duration::from_secs(3)).await;
}
}
}
}
// extract_chatgpt_account_id moved to util.rs
/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages.
+105 -12
View File
@@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::process::Command;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::RwLock;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
@@ -506,14 +507,18 @@ async fn execute_agent(agent_id: String, config: Option<AgentConfig>) {
drop(manager);
// Execute with full permissions in the worktree
execute_model_with_permissions(
&model,
&full_prompt,
false,
Some(worktree_path),
config.clone(),
)
.await
if model.to_ascii_lowercase() == "cloud" && config.is_none() {
execute_cloud_built_in_streaming(&agent_id, &full_prompt, Some(worktree_path), config.clone()).await
} else {
execute_model_with_permissions(
&model,
&full_prompt,
false,
Some(worktree_path),
config.clone(),
)
.await
}
}
Err(e) => Err(format!("Failed to setup worktree: {}", e)),
}
@@ -526,7 +531,11 @@ async fn execute_agent(agent_id: String, config: Option<AgentConfig>) {
"{}\n\n[Running in read-only mode - no modifications allowed]",
full_prompt
);
execute_model_with_permissions(&model, &full_prompt, true, None, config).await
if model.to_ascii_lowercase() == "cloud" && config.is_none() {
execute_cloud_built_in_streaming(&agent_id, &full_prompt, None, config).await
} else {
execute_model_with_permissions(&model, &full_prompt, true, None, config).await
}
};
// Update result
@@ -594,7 +603,7 @@ async fn execute_model_with_permissions(
// `codex` binary to be present on PATH. This improves portability,
// especially on Windows where global shims may be missing.
let model_lower = model.to_lowercase();
let mut cmd = if (model_lower == "code" || model_lower == "codex") && config.is_none() {
let mut cmd = if ((model_lower == "code" || model_lower == "codex") || model_lower == "cloud") && config.is_none() {
match std::env::current_exe() {
Ok(path) => Command::new(path),
Err(e) => return Err(format!("Failed to resolve current executable: {}", e)),
@@ -646,6 +655,7 @@ async fn execute_model_with_permissions(
model_lower.as_str()
};
let built_in_cloud = family == "cloud" && config.is_none();
match family {
"claude" | "gemini" | "qwen" => {
let mut defaults = crate::agent_defaults::default_params_for(family, read_only);
@@ -664,8 +674,10 @@ async fn execute_model_with_permissions(
cmd.args(defaults);
}
}
// Cloud agent: append prompt positionally; do not assume a -p flag.
// Cloud agent: built-in path uses `code cloud submit <prompt>`; external
// command path falls back to positional prompt with optional defaults.
"cloud" => {
if built_in_cloud { cmd.args(["cloud", "submit", "--wait"]); }
// If config provided explicit args for this mode, do not append defaults.
let have_mode_args = config
.as_ref()
@@ -757,7 +769,7 @@ async fn execute_model_with_permissions(
// Intentionally build args fresh for sandbox helpers; `Command` does not expose argv.
// Rebuild the invocation as `command` + args set above.
// We reconstruct to run under our sandbox helpers.
let program = if (model_lower == "code" || model_lower == "codex") && config.is_none() {
let program = if ((model_lower == "code" || model_lower == "codex") || model_lower == "cloud") && config.is_none() {
// Use current exe path
std::env::current_exe().map_err(|e| format!("Failed to resolve current executable: {}", e))?
} else {
@@ -783,6 +795,7 @@ async fn execute_model_with_permissions(
}
}
let built_in_cloud = family == "cloud" && config.is_none();
match family {
"claude" | "gemini" | "qwen" => {
let mut defaults = crate::agent_defaults::default_params_for(family, read_only);
@@ -801,6 +814,7 @@ async fn execute_model_with_permissions(
}
}
"cloud" => {
if built_in_cloud { args.extend(["cloud", "submit", "--wait"].map(String::from)); }
let have_mode_args = config
.as_ref()
.map(|c| if read_only { c.args_read_only.is_some() } else { c.args_write.is_some() })
@@ -897,6 +911,85 @@ async fn execute_model_with_permissions(
}
}
/// Execute the built-in cloud agent via the current `code` binary, streaming
/// stderr lines into the HUD as progress and returning final stdout. Applies a
/// modest truncation cap to very large outputs to keep UI responsive.
async fn execute_cloud_built_in_streaming(
agent_id: &str,
prompt: &str,
working_dir: Option<std::path::PathBuf>,
_config: Option<AgentConfig>,
) -> Result<String, String> {
// Program and argv
let program = std::env::current_exe()
.map_err(|e| format!("Failed to resolve current executable: {}", e))?;
let args: Vec<String> = vec![
"cloud".into(),
"submit".into(),
"--wait".into(),
prompt.into(),
];
// Baseline env mirrors behavior in execute_model_with_permissions
let env: std::collections::HashMap<String, String> = std::env::vars().collect();
use crate::protocol::SandboxPolicy;
use crate::spawn::StdioPolicy;
let mut child = crate::spawn::spawn_child_async(
program.clone(),
args.clone(),
Some(&program.to_string_lossy()),
working_dir.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))),
&SandboxPolicy::DangerFullAccess,
StdioPolicy::RedirectForShellTool,
env,
)
.await
.map_err(|e| format!("Failed to spawn cloud submit: {}", e))?;
// Stream stderr to HUD
let stderr_task = if let Some(stderr) = child.stderr.take() {
let agent = agent_id.to_string();
Some(tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let msg = line.trim();
if msg.is_empty() { continue; }
let mut mgr = AGENT_MANAGER.write().await;
mgr.add_progress(&agent, msg.to_string()).await;
}
}))
} else { None };
// Collect stdout fully (final result)
let mut stdout_buf = String::new();
if let Some(stdout) = child.stdout.take() {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
stdout_buf.push_str(&line);
stdout_buf.push('\n');
}
}
let status = child.wait().await.map_err(|e| format!("Failed to wait: {}", e))?;
if let Some(t) = stderr_task { let _ = t.await; }
if !status.success() {
return Err(format!("cloud submit exited with status {}", status));
}
// Truncate large outputs
const MAX_BYTES: usize = 500_000; // ~500 KB
if stdout_buf.len() > MAX_BYTES {
let omitted = stdout_buf.len() - MAX_BYTES;
let mut truncated = String::with_capacity(MAX_BYTES + 128);
truncated.push_str(&stdout_buf[..MAX_BYTES]);
truncated.push_str(&format!("\n… [truncated: {} bytes omitted]", omitted));
Ok(truncated)
} else {
Ok(stdout_buf)
}
}
// Tool creation functions
pub fn create_run_agent_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
+2 -2
View File
@@ -6516,8 +6516,8 @@ pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, argument
"claude" => ("claude".to_string(), false),
"gemini" => ("gemini".to_string(), false),
"qwen" => ("qwen".to_string(), false),
// Cloud agent treated as external CLI by default.
"cloud" => ("cloud".to_string(), false),
// Cloud agent: treat as built-in via current executable (code cloud submit)
"cloud" => ("cloud".to_string(), true),
_ => (m, false),
}
}