Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ zeroize = { workspace = true }
nostr = { workspace = true }

# Async runtime
tokio = { workspace = true }
tokio = { workspace = true, features = ["io-std"] }

# WebSocket
tokio-tungstenite = { workspace = true }
Expand Down Expand Up @@ -80,8 +80,9 @@ evalexpr = { workspace = true }
# Process-group kill (safe wrapper around killpg) — Unix-only; kill_process_group
# has a #[cfg(not(unix))] fallback in acp.rs.
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", default-features = false, features = ["signal"] }
nix = { version = "0.31", default-features = false, features = ["signal", "user"] }

[dev-dependencies]
axum = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
httparse = "1"
5 changes: 5 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# buzz-acp

For one prepared local task, use **`buzz-acp run --task <path|->`**. See
[Local task runner and version-1 task contract](TASKS.md). With no command,
`buzz-acp` remains the conversational service.


ACP harness that connects AI agents to Buzz. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using the Buzz CLI.

```
Expand Down
153 changes: 153 additions & 0 deletions crates/buzz-acp/TASKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Local task runner

`buzz-acp run` runs one prepared task in one fresh agent process and ACP session,
then exits. Scripts and automation can use it without starting a conversational
`buzz-acp` service or sending a chat message.

```sh
buzz-acp run --task ./task.json
cat task.json | buzz-acp run --task -
```

Put command options after `run`. Use the normal trusted launch settings for
identity, relay, adapter, model, effort, permissions, prompts, MCP tools, and
memory. `buzz-acp run --help` lists them. The working directory is the launch
working directory, not the task file's parent. The task cannot change it.

## Task document, version 1

Supply exactly one UTF-8 JSON object, at most 1 MiB including whitespace:

```json
{
"version": 1,
"taskId": "example-001",
"agentPubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"prompt": "Inspect the current workspace. Report your findings using the task's specified destination.",
"maxDurationMs": 120000
}
```

The example public key is illustrative; use the public key of the configured
agent. Never put a private key in a task document.

| Field | Contract |
| --- | --- |
| `version` | Integer `1`. Other versions fail before agent launch. |
| `taskId` | Nonblank string, 1–256 UTF-8 bytes, no control characters. Correlation only, not a claim or deduplication key. |
| `agentPubkey` | Exactly 64 lowercase hexadecimal characters. Must equal the configured agent's public key. |
| `prompt` | Nonblank prepared task string. Include relevant context, original evidence, and reporting instructions here. |
| `maxDurationMs` | Positive unsigned 64-bit integer. The shorter of this limit and the trusted `--max-turn-duration` cap wins. |

All fields are required. Unknown fields, duplicate fields, wrong types, trailing
JSON documents, and malformed JSON are rejected. File/stdin input has a ten-second
read limit. Close stdin after the document. The input limit is separate from the
execution deadline.

Launch configuration is the authority for credentials, executable, tools,
permissions, and relay. A document is **executable task input**, not a sandboxed
message: only feed tasks that are trusted to use that agent's capabilities. The
identity check prevents accidental execution by another agent; it does not
verify the document's author. File/stdin input does not need a signature.

## Session behavior

- Load normal base, persona, and team instructions. Add the task-session context
instead of a conversational session description. Modern adapters receive
standing instructions through session setup; legacy adapters receive them with
the first prompt. `--no-base-prompt` and custom base files still work.
- Load enabled core memory for the resolved owner and identity before session
creation. `--no-memory` skips this. No owner means no memory namespace. The
normal three-second fetch bound applies. Confirmed absence supplies the normal
onboarding hint; a failed read supplies no hint, not a false empty-memory claim.
- Apply model, supported effort and permission settings through the existing
session setup. Unsupported settings retain the existing adapter fallback
behavior. Pass configured identity and relay to the child and MCP tools; keep
the inherited authorization and other launch environment. The shared runtime
prepares Git identity, scoped credentials, and signing helpers for both task
and conversational sessions. Temporary key material lives until adapter
cleanup is complete.
- Submit one prepared task, allowing multiple model/tool exchanges. There is no
initial-message turn, heartbeat turn, conversation history, automatic channel
context, or second task. Context must be supplied or fetched with tools.
- Do not start ordinary relay subscriptions, a standing pool, presence, typing,
a setup listener, observer publication, or an isolated-turn socket. Inherited
service-only options cannot enable these features. Shared option parsing still
validates launch settings.
- Do not discover, borrow, stop, or reconfigure an existing host. Memory, files,
credentials, and external side effects may be shared with concurrent sessions.
Session isolation is not a security sandbox or a concurrency lock.
- Do not automatically publish final text. Use task reporting instructions and
tools. Do not retry the task after failure, timeout, or uncertain execution.

Launch configuration preparation (including prompt-file reads) has a separate
ten-second bound. A blocked configuration read reports `configuration_timeout`
with exit 2; SIGINT/SIGTERM during that read reports `cancelled` with exit
130/143 and a null `taskId`. No adapter is started during this phase.

The execution deadline covers adapter startup, memory loading, session setup,
and the task turn. SIGINT/SIGTERM cancel the invocation. If a prompt is active,
allow up to five seconds for cooperative cancellation, then kill and reap the
child, with a further five-second reap bound. Startup and setup cancellation do
not wait for a session to become ready. On Unix, cleanup kills the process group,
including ordinary descendants. Processes that deliberately escape that group
are outside this guarantee. On non-Unix platforms cleanup uses the existing
ACP direct-child cleanup; descendant containment is not promised.

## Terminal output

For each attempted invocation, stdout contains one JSON line (except help/version
output). Diagnostics and child stderr stay on stderr. No prompt, private key,
agent final text, or raw adapter error is included in the terminal record.

```json
{"version":1,"taskId":"example-001","status":"completed","stopReason":"end_turn"}
```

`taskId` is null until the document is validated. `status` is one of `completed`,
`failed`, `invalid`, `timed_out`, or `cancelled`. `stopReason` is present only for
normal completion: `end_turn`, `max_tokens`, `max_turn_requests`, or `refusal`.
Failures include a stable, sanitized `error` code when available, such as
`agent_identity_mismatch`, `invalid_task_json`, or `agent_spawn_failed`.

| Exit | Meaning |
| --- | --- |
| `0` | Turn returned normally. Inspect `stopReason`; this is not proof of task success. |
| `1` | Startup/execution failure, adapter cancellation, or terminal-output failure. |
| `2` | Invalid arguments, launch configuration, or task input/read. |
| `124` | Execution deadline exceeded. |
| `130` | SIGINT; bounded cleanup ran. |
| `143` | SIGTERM; bounded cleanup ran. |

A killed host or broken stdout can leave no terminal record. A record is not a
persistent result ledger. Launching the same task twice can execute it twice.

## Future work

URL input and task-server retrieval are **not implemented**. URL-shaped sources
are rejected, not fetched. A future design can GET a supplied URL and validate
the same task document, with explicit authentication, size/time bounds, and
redirect rules. This project adds no HTTP task endpoint or job queue.

## Shared runtime ownership

`runtime::AgentRuntime` prepares shared capabilities and owns their lifetime.
Both entry points obtain adapter configuration and prompt context from it;
add new shared capabilities there rather than in either entry point. Session
creation, tool configuration, model/effort/permissions, and legacy prompt
framing use the existing pool code. Mode-specific code owns task input and
terminal output or conversational intake and scheduling, not agent equipment.

## Build and verification

```sh
. ./bin/activate-hermit
cargo build --release -p buzz-acp
cargo test -p buzz-acp
```

`tests/run_task.rs` invokes the actual binary with a deterministic ACP peer. It
checks stdin/file parity, fresh process/session setup, legacy and modern standing
instructions, tools, identity, model/effort/permissions, memory reads and opt-out,
invalid input, clean JSON, deadlines, signals, and Unix descendant cleanup.
These checks do not require a model provider or production relay.
13 changes: 13 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,18 @@ impl AcpClient {
args: &[String],
extra_env: &[(String, String)],
has_generated_codex_config: bool,
) -> Result<Self, AcpError> {
Self::spawn_with_env(command, args, extra_env, has_generated_codex_config, &[]).await
}

/// Spawn with authoritative launch environment overrides. Unlike persona
/// defaults, these values take precedence over the inherited environment.
pub(crate) async fn spawn_with_env(
command: &str,
args: &[String],
extra_env: &[(String, String)],
has_generated_codex_config: bool,
launch_env: &[(String, String)],
) -> Result<Self, AcpError> {
use std::process::Stdio;

Expand Down Expand Up @@ -572,6 +584,7 @@ impl AcpClient {
"codex" | "codex-acp" => Some(StandardAdapterKind::Codex),
_ => None,
};
cmd.envs(launch_env.iter().cloned());
let mut child = cmd.spawn()?;

let stdin = child
Expand Down
3 changes: 2 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ pub struct AuthenticateArgs {
#[derive(Debug, Parser)]
#[command(
name = "buzz-acp",
about = "ACP harness that bridges Buzz events to AI agents"
about = "ACP harness that bridges Buzz events to AI agents",
after_help = "Commands: run (one local task), models, auth-methods, authenticate.\nUse buzz-acp <COMMAND> --help. With no command, start the conversational service."
)]
pub struct CliArgs {
#[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")]
Expand Down
12 changes: 12 additions & 0 deletions crates/buzz-acp/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ pub(crate) struct GitEnvironment {
}

impl GitEnvironment {
pub(crate) fn for_config(config: &mut crate::config::Config) -> anyhow::Result<Self> {
let environment =
Self::install(&config.keys, &config.relay_url, &std::env::current_exe()?)?;
config
.persona_env_vars
.retain(|(name, _)| !is_managed_env(name));
config
.persona_env_vars
.extend(environment.env.iter().cloned());
Ok(environment)
}

pub(crate) fn install(
keys: &nostr::Keys,
relay_url: &str,
Expand Down
95 changes: 95 additions & 0 deletions crates/buzz-acp/src/isolated_execution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! One fresh process/session for a local task, with bounded cleanup.
use crate::{acp, pool, AcpClient, OwnedAgent, PoolStartup, PromptContext, SessionState};
use std::time::Duration;
use tokio::sync::oneshot;

pub(crate) enum Outcome {
Completed(String),
Cancelled,
Deadline,
Failed(&'static str),
}

pub(crate) async fn execute(
startup: &PoolStartup,
ctx: &PromptContext,
prompt: &str,
duration: Duration,
mut cancel: oneshot::Receiver<()>,
) -> Outcome {
if !matches!(cancel.try_recv(), Err(oneshot::error::TryRecvError::Empty)) {
return Outcome::Cancelled;
}
let deadline = tokio::time::Instant::now() + duration;
let mut acp = match AcpClient::spawn(
&startup.command,
&startup.args,
&startup.extra_env,
startup.has_generated_codex_config,
)
.await
{
Ok(acp) => acp,
Err(_) => return Outcome::Failed("agent_spawn_failed"),
};
let initialized = tokio::select! {
biased;
_ = &mut cancel => Err(Outcome::Cancelled),
_ = tokio::time::sleep_until(deadline) => Err(Outcome::Deadline),
result = acp.initialize() => result.map_err(|_| Outcome::Failed("agent_initialize_failed")),
};
let init = match initialized {
Ok(init) => init,
Err(outcome) => {
acp.shutdown().await;
return outcome;
}
};
let mut agent = OwnedAgent {
index: 0,
acp,
state: SessionState::default(),
model_capabilities: None,
desired_model: startup.model.clone(),
model_overridden: false,
desired_model_request_id: None,
desired_model_pending_ack: false,
startup_effort: startup.effort_level.clone(),
agent_name: crate::normalized_agent_name(&init),
goose_system_prompt_supported: None,
protocol_version: init["protocolVersion"].as_u64().unwrap_or(1) as u32,
};
let mut session = None;
let outcome = tokio::select! {
biased;
_ = &mut cancel => Outcome::Cancelled,
_ = tokio::time::sleep_until(deadline) => Outcome::Deadline,
result = pool::run_isolated_prompt(&mut agent, ctx, prompt, duration, &mut session) => {
match result {
Ok(acp::StopReason::Cancelled) => Outcome::Cancelled,
Ok(acp::StopReason::EndTurn) => Outcome::Completed("end_turn".into()),
Ok(acp::StopReason::MaxTokens) => Outcome::Completed("max_tokens".into()),
Ok(acp::StopReason::MaxTurnRequests) => Outcome::Completed("max_turn_requests".into()),
Ok(acp::StopReason::Refusal) => Outcome::Completed("refusal".into()),
Err(acp::AcpError::HardTimeout { .. }) => Outcome::Deadline,
Err(acp::AcpError::IdleTimeout(_)) => Outcome::Failed("agent_idle_timeout"),
Err(_) => Outcome::Failed("agent_execution_failed"),
}
}
};
if matches!(outcome, Outcome::Cancelled | Outcome::Deadline) {
if let Some(session) = session {
// Cancellation is best effort; the terminal status remains the cause
// that won above, even if the adapter returns end_turn during drain.
let _ = tokio::time::timeout(
Duration::from_secs(5),
agent
.acp
.cancel_with_cleanup_grace(&session, Duration::from_secs(5)),
)
.await;
}
}
agent.acp.shutdown().await;
outcome
}
Loading
Loading