mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
🤖 ## Summary Scripts, scheduled jobs, and other automation need to give an agent one prepared task and wait for a result without starting a long-running chat listener. This adds `buzz-acp run`: it reads a task from a local file or stdin, runs one fresh agent session with the configured tools and instructions, cleans up, and exits with a machine-readable status. ```sh buzz-acp run --task ./task.json cat task.json | buzz-acp run --task - ``` - The versioned JSON task specifies a task ID, intended agent public key, prepared prompt, and execution time limit. Invalid input or an identity mismatch is rejected before agent launch. Credentials, executable, tools, and permissions remain controlled by trusted launch settings, not the task document. - Each invocation uses the normal agent equipment: base/persona/team instructions, enabled memory, tools, model and effort settings, permissions, and Git identity, credentials, and signing. Both task and conversational modes obtain these capabilities from one shared runtime owner; new shared setup belongs there rather than in separate entry points. - Launch configuration reads have a separate ten-second bound and can be cancelled even when a prompt file blocks. A deadline covers adapter startup through the task turn. Interrupts and termination signals trigger bounded cleanup. Stdout contains one terminal JSON record; diagnostics stay on stderr. A normal turn ending is not proof that the task achieved its goal. - Normal invocation remains the conversational service. Task mode does not start chat subscriptions, presence, heartbeat turns, or a persistent agent pool. Tasks are trusted executable instructions, not sandboxed input. Reusing a task ID does not prevent duplicate execution, and uncertain outcomes must not be blindly retried. See [the task contract](crates/buzz-acp/TASKS.md) for the document format, exit codes, limits, and examples. URL retrieval, a task server, and a persistent execution ledger are outside this change. ### Related issue None found in the existing issue and PR search. ### Testing - At `4e86bbc97`: 964 unit tests and 22 integration tests passed, with three existing ignored tests. The package suite ran serially with inherited harness/Git options cleared. A final test-only shutdown-result assertion was then checked with the focused memory test and all-target Clippy. - All eight real-binary task tests independently passed on the exact candidate, including blocked configuration reads, file/stdin validation, session equipment, deadlines, cancellation, and cleanup. The memory test passed 20 repeated runs. Removing the oversized-input guard fails the new error assertion; restoring it passes. - Package all-target Clippy, formatting, file-size checks, and pre-push Rust/Tauri checks passed. All executed hosted checks passed on the updated head (54 successful, 28 skipped). The separate Codex review comment still requests authorization for this exact range. - Earlier revisions passed the release build and a real Goose 1.51.0 tool-use smoke test. No new release artifact or installed Janet runtime is claimed for this feedback revision. - Repository-wide `just ci` was previously attempted; workspace Clippy passed, then the desktop dependency check stopped on a local pnpm fetch failure. Hosted checks cover the applicable PR lanes. --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
286 lines
9.8 KiB
Rust
286 lines
9.8 KiB
Rust
//! Local version-1 task input and terminal CLI contract. No service startup.
|
|
use crate::{
|
|
config::{CliArgs, Config},
|
|
isolated_execution::{self, Outcome},
|
|
runtime::{AgentRuntime, SessionMode},
|
|
};
|
|
use clap::Parser;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{io::Write, time::Duration};
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
const MAX_TASK_BYTES: u64 = 1024 * 1024;
|
|
const INPUT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "buzz-acp run",
|
|
about = "Run one local task in a fresh agent session, then exit",
|
|
after_help = "--task accepts a local file or - (stdin). URLs are not supported.\nExisting launch options are accepted; service-only options do not start service features.\nstdout: one version-1 JSON terminal record; stderr: diagnostics.\nExit: 0 completed, 1 failed/cancelled, 2 invalid input/config, 124 deadline, 130 SIGINT, 143 SIGTERM."
|
|
)]
|
|
struct RunArgs {
|
|
/// One versioned JSON task from a local file, or - for stdin.
|
|
#[arg(long, value_name = "PATH|-")]
|
|
task: String,
|
|
#[command(flatten)]
|
|
launch: CliArgs,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct Task {
|
|
version: u8,
|
|
task_id: String,
|
|
agent_pubkey: String,
|
|
prompt: String,
|
|
max_duration_ms: u64,
|
|
}
|
|
|
|
impl Task {
|
|
fn parse(bytes: &[u8]) -> Result<Self, &'static str> {
|
|
let task: Self = serde_json::from_slice(bytes).map_err(|_| "invalid_task_json")?;
|
|
if task.version != 1 {
|
|
return Err("unsupported_task_version");
|
|
}
|
|
if task.task_id.is_empty()
|
|
|| task.task_id.len() > 256
|
|
|| task.task_id.chars().any(char::is_control)
|
|
|| task.task_id.trim().is_empty()
|
|
|| task.prompt.trim().is_empty()
|
|
|| task.max_duration_ms == 0
|
|
|| task.agent_pubkey.len() != 64
|
|
|| !task
|
|
.agent_pubkey
|
|
.bytes()
|
|
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
|
{
|
|
return Err("invalid_task_fields");
|
|
}
|
|
Ok(task)
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct Terminal {
|
|
version: u8,
|
|
task_id: Option<String>,
|
|
status: &'static str,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
stop_reason: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error: Option<&'static str>,
|
|
}
|
|
impl Terminal {
|
|
fn new(status: &'static str, error: Option<&'static str>) -> Self {
|
|
Self {
|
|
version: 1,
|
|
task_id: None,
|
|
status,
|
|
error,
|
|
stop_reason: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn read_task(source: &str) -> Result<Task, &'static str> {
|
|
// Reserve URL-shaped sources for a future explicit retrieval contract.
|
|
if source.contains("://") {
|
|
return Err("remote_task_not_supported");
|
|
}
|
|
let mut bytes = Vec::new();
|
|
let read = async {
|
|
if source == "-" {
|
|
tokio::io::stdin()
|
|
.take(MAX_TASK_BYTES + 1)
|
|
.read_to_end(&mut bytes)
|
|
.await
|
|
} else {
|
|
tokio::fs::File::open(source)
|
|
.await?
|
|
.take(MAX_TASK_BYTES + 1)
|
|
.read_to_end(&mut bytes)
|
|
.await
|
|
}
|
|
};
|
|
tokio::time::timeout(INPUT_TIMEOUT, read)
|
|
.await
|
|
.map_err(|_| "task_input_timeout")?
|
|
.map_err(|_| "task_read_failed")?;
|
|
if bytes.len() as u64 > MAX_TASK_BYTES {
|
|
return Err("task_too_large");
|
|
}
|
|
Task::parse(&bytes)
|
|
}
|
|
|
|
pub(crate) async fn run() -> i32 {
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_writer(std::io::stderr)
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "buzz_acp=info".into()),
|
|
)
|
|
.compact()
|
|
.try_init();
|
|
let args = RunArgs::try_parse_from(std::env::args_os().skip(1));
|
|
let mut args = match args {
|
|
Ok(args) => args,
|
|
Err(error)
|
|
if matches!(
|
|
error.kind(),
|
|
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
|
|
) =>
|
|
{
|
|
let _ = error.print();
|
|
return 0;
|
|
}
|
|
Err(_) => return emit(Terminal::new("invalid", Some("invalid_arguments")), 2),
|
|
};
|
|
// These settings belong only to conversation admission and repeated work.
|
|
args.launch.heartbeat_interval = 0;
|
|
args.launch.heartbeat_prompt = None;
|
|
args.launch.heartbeat_prompt_file = None;
|
|
args.launch.turn_liveness_secs = 0;
|
|
let (signal_tx, mut signal_rx) = tokio::sync::oneshot::channel();
|
|
// Register before configuration: launch prompt files can also block on I/O.
|
|
#[cfg(unix)]
|
|
let signals = (
|
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()),
|
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()),
|
|
);
|
|
#[cfg(unix)]
|
|
let signal_task = match signals {
|
|
(Ok(mut interrupt), Ok(mut terminate)) => tokio::spawn(async move {
|
|
let code = tokio::select! { _ = interrupt.recv() => 130, _ = terminate.recv() => 143 };
|
|
let _ = signal_tx.send(code);
|
|
}),
|
|
_ => return emit(Terminal::new("failed", Some("signal_setup_failed")), 1),
|
|
};
|
|
#[cfg(not(unix))]
|
|
let signal_task = tokio::spawn(async move {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
let _ = signal_tx.send(130);
|
|
});
|
|
// Config owns synchronous prompt-file reads. Keep them off the async worker
|
|
// so signals and the preparation bound remain live, including for FIFOs.
|
|
// This worker only builds configuration; it cannot spawn an agent or create
|
|
// runtime resources. On cancellation/timeout the CLI's bounded runtime
|
|
// shutdown and process exit also retire a still-blocked read.
|
|
let configuration = tokio::task::spawn_blocking(move || Config::from_args(args.launch));
|
|
let config = tokio::select! {
|
|
biased;
|
|
code = &mut signal_rx => return emit(Terminal::new("cancelled", None), code.unwrap_or(1)),
|
|
result = tokio::time::timeout(INPUT_TIMEOUT, configuration) => match result {
|
|
Ok(Ok(Ok(config))) if config.max_turn_duration_secs > 0 => config,
|
|
Err(_) => {
|
|
signal_task.abort();
|
|
return emit(Terminal::new("invalid", Some("configuration_timeout")), 2);
|
|
}
|
|
_ => {
|
|
signal_task.abort();
|
|
return emit(Terminal::new("invalid", Some("invalid_configuration")), 2);
|
|
}
|
|
},
|
|
};
|
|
let task = tokio::select! {
|
|
biased;
|
|
code = &mut signal_rx => return emit(Terminal::new("cancelled", None), code.unwrap_or(1)),
|
|
task = read_task(&args.task) => task,
|
|
};
|
|
let task = match task {
|
|
Ok(task) => task,
|
|
Err(error) => {
|
|
signal_task.abort();
|
|
return emit(Terminal::new("invalid", Some(error)), 2);
|
|
}
|
|
};
|
|
let mut terminal = Terminal::new("invalid", None);
|
|
terminal.task_id = Some(task.task_id);
|
|
if task.agent_pubkey != config.keys.public_key().to_hex() {
|
|
terminal.error = Some("agent_identity_mismatch");
|
|
signal_task.abort();
|
|
return emit(terminal, 2);
|
|
}
|
|
// Keep signing material alive until execution has drained and reaped the adapter.
|
|
let runtime = match AgentRuntime::prepare(config) {
|
|
Ok(environment) => environment,
|
|
Err(_) => {
|
|
terminal.status = "failed";
|
|
terminal.error = Some("runtime_setup_failed");
|
|
signal_task.abort();
|
|
return emit(terminal, 1);
|
|
}
|
|
};
|
|
let config = runtime.config();
|
|
let rest = crate::relay::RestClient {
|
|
http: reqwest::Client::new(),
|
|
base_url: crate::relay::relay_ws_to_http(&config.relay_url),
|
|
keys: config.keys.clone(),
|
|
auth_tag_json: std::env::var("BUZZ_AUTH_TAG")
|
|
.ok()
|
|
.filter(|s| !s.is_empty()),
|
|
};
|
|
let ctx = match runtime.prompt_context(rest, Default::default(), SessionMode::Task) {
|
|
Ok(ctx) => ctx,
|
|
Err(_) => {
|
|
terminal.error = Some("invalid_working_directory");
|
|
return emit(terminal, 2);
|
|
}
|
|
};
|
|
let startup = runtime.startup(None);
|
|
let duration = Duration::from_millis(
|
|
task.max_duration_ms
|
|
.min(config.max_turn_duration_secs.saturating_mul(1000)),
|
|
);
|
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
|
let execution = isolated_execution::execute(&startup, &ctx, &task.prompt, duration, cancel_rx);
|
|
tokio::pin!(execution);
|
|
let (outcome, signal) = tokio::select! {
|
|
biased;
|
|
code = &mut signal_rx => {
|
|
let _ = cancel_tx.send(());
|
|
(execution.await, Some(code.unwrap_or(1)))
|
|
}
|
|
outcome = &mut execution => (outcome, None),
|
|
};
|
|
signal_task.abort();
|
|
let code = if let Some(code) = signal {
|
|
terminal.status = "cancelled";
|
|
code
|
|
} else {
|
|
match outcome {
|
|
Outcome::Completed(reason) => {
|
|
terminal.status = "completed";
|
|
terminal.stop_reason = Some(reason);
|
|
0
|
|
}
|
|
Outcome::Cancelled => {
|
|
terminal.status = "cancelled";
|
|
1
|
|
}
|
|
Outcome::Deadline => {
|
|
terminal.status = "timed_out";
|
|
124
|
|
}
|
|
Outcome::Failed(error) => {
|
|
terminal.status = "failed";
|
|
terminal.error = Some(error);
|
|
1
|
|
}
|
|
}
|
|
};
|
|
emit(terminal, code)
|
|
}
|
|
|
|
fn emit(terminal: Terminal, code: i32) -> i32 {
|
|
let mut out = std::io::stdout().lock();
|
|
if serde_json::to_writer(&mut out, &terminal).is_err()
|
|
|| out.write_all(b"\n").is_err()
|
|
|| out.flush().is_err()
|
|
{
|
|
return 1;
|
|
}
|
|
code
|
|
}
|