feat: add --headless flag for unattended operation

This commit is contained in:
2026-07-27 17:17:56 -06:00
parent af5c34fde5
commit f1415067f2
5 changed files with 162 additions and 1 deletions
+82
View File
@@ -0,0 +1,82 @@
# Design: unattended mode (`--headless`) and ACP agent server (`--acp-server`)
**Status:** proposed · **Scope:** this repo only · **Motivation:** make Coyote drivable by machines, not just humans — CI pipelines, cron jobs, external orchestrators, and any [Agent Client Protocol](https://agentclientprotocol.com) client (Zed editors, agent fleet managers, test harnesses).
## Feature 1 — `--headless`: never block waiting for a human
### Problem
Coyote assumes a terminal in three places, and an unattended invocation (cron, CI, a supervisor process driving `coyote "<prompt>"`) hangs or fails at each:
1. **User-interaction tools** (`user__select` / `user__confirm` / `user__input` / `user__checkbox`) at `depth == 0` route to live `inquire` prompts (`src/function/user_interaction.rs:139-145`, handlers `:148-210`). No TTY → hang or error. (Depth >0 already escalates to the parent-agent queue — a top-level unattended run IS depth 0.)
2. **The shell execute guard** — already TTY-gated (`src/main.rs:596-657` checks `IS_STDOUT_TERMINAL`) but should be contractual, not incidental.
3. **Rendering**`markdown_stream()` uses crossterm raw mode (`src/render/stream.rs:17`); unattended runs must take `raw_stream()`/silent deterministically.
### Why a flag instead of TTY detection
Non-TTY stdout does not mean no human: `coyote "explain" | tee out.md` pipes stdout with a fully present user, and `inquire` prompts render on stderr — prompting during piped output is correct today. Only the *launcher* knows nobody is at the keyboard; the flag states it.
### Change
- `src/cli/mod.rs`: `#[arg(long, help_heading = "Sandbox")] pub headless: bool`.
- `src/utils/mod.rs` (beside `IS_STDOUT_TERMINAL:45`): process-wide `HEADLESS: AtomicBool`.
- `src/main.rs` (~`:60-64`, the `--dangerously-skip-permissions` pattern): set `AUTO_CONFIRM=true`, set HEADLESS, force the non-interactive render path. `--headless` with no prompt (REPL mode) is a contradiction → `bail!`.
- `src/function/user_interaction.rs:139-145`: three-way route — headless ⇒ the tool immediately returns structured JSON to the model (`{"needs_human": true, "action": …, "question": …, "options": […], "guidance": …}`); agent configs own what to do with it (report upstream, apply a default, or fail the task). The binary only guarantees "never block."
- **Graph agents are covered for free**: graph user-interaction nodes call the same `handle_user_tool` (`src/graph/user_interaction.rs:4,24,59`) — one graph-path test locks it.
~50 LOC + tests. Composes three existing mechanisms (AUTO_CONFIRM env, IS_STDOUT_TERMINAL gating, the depth router).
## Feature 2 — `--acp-server`: Coyote as an ACP agent over stdio
### What ACP mode is
A JSON-RPC 2.0 stdio server implementing the ACP **agent** side, so any ACP client (an editor, an orchestrator, a test harness) can create sessions, send prompts, stream progress, relay user-interaction requests, cancel turns, and resume from transcripts. Ground truth for protocol shapes: the ACP spec and Zed's `agent-client-protocol` Rust crate (evaluate for adoption; fallback is ~300 LOC of hand-rolled framing — the protocol surface below is small).
### Method surface (initial)
Inbound: `initialize`, `session/new`, `session/prompt`, `session/load`, `session/cancel`. Outbound: `session/update` (streaming), `session/request_permission`. Anything else → `-32601`. Single session per server process initially (the process-per-agent model our launchers use); a second `session/new` errors.
### Mappings — each reuses machinery this repo already has
| ACP | Implementation |
|---|---|
| `session/new` | fork a `RequestContext` (child-fork pattern: `new_for_child`, `src/function/supervisor.rs:554`); `RenderMode::Silent` (`src/config/request_context.rs:163`) |
| `session/prompt` | one turn: `Input::from_str``call_chat_completions_streaming` (`src/client/common.rs:478`) with an `SseHandler` sink (`:485`) emitting `session/update` chunks and tool-call updates; reply `{stopReason: "end_turn"}` after the tool loop settles (mirror the minimal loop from `repl/mod.rs ask():1241` — do not call the REPL fn). `auto_continue` forced OFF in ACP mode: the client drives turns; internal self-continuation would desync stop reasons |
| `session/load` | transcript replay = the `--session` resume path (`src/config/session.rs:19-99`; load `:129-138`) — `messages` + `compressed_messages` are the transcript, tool results included; reconstruct history, never re-execute tools |
| `session/cancel` | `abort_signal.set_ctrlc()` (`src/utils/abort_signal.rs:53`) — already polled by streaming + graph executor; interrupted prompt still replies with the cancelled stop reason |
| `user__*``session/request_permission` | fourth arm in the interaction router: send the permission request, await with the EXISTING escalation-timeout discipline (300 s default, `user_interaction.rs:14,238-242`); secret-class prompts (device-code/login-shaped) carry a classification marker so clients can render them without a free-text return channel; headless JSON fallback if the permission call fails |
### stdout purity (the load-bearing constraint)
In ACP mode every byte on stdout must parse as JSON-RPC. Known contamination sources, by inspection: log4rs `ConsoleAppender` defaults to stdout (`src/main.rs:693-694`) — ACP mode parameterizes the existing logger builder to stderr/file; ~20 `print!/println!` sites in `src/main.rs`/`src/client/common.rs` — bypassed via `RenderMode::Silent`, with all ACP stdout writes funneled through one serializer. Locked by a test that drives a full session through a spawned child process and asserts line-by-line parseability. Spinners already write to stderr.
`--acp-server` implies `--headless` semantics.
600-900 LOC incl. tests. Testable end-to-end with a stubbed provider endpoint and an in-repo minimal ACP client harness — no network, no real keys.
## Feature 3 — kit: a headless profile (env-parameterized)
The sandbox kit (`assets/sbx-kit/spec.yaml`) gains a **headless profile** for sandboxes created by external supervisors rather than `coyote --sandbox`:
- Entry: `coyote --headless --agent "${COYOTE_HEADLESS_AGENT}" "${COYOTE_HEADLESS_PROMPT:-$(cat "${COYOTE_PROMPT_FILE}")}"` — the supervisor injects `COYOTE_HEADLESS_AGENT` and either a literal `COYOTE_HEADLESS_PROMPT` or a `COYOTE_PROMPT_FILE` path at sandbox-create time. (If the kit command schema can't express shell substitution, the fallback is `-f "${COYOTE_PROMPT_FILE}"` — file-only input still selects Cmd mode, `src/main.rs:77-82`.)
- Fresh-config semantics: no host-config projection (agents/config arrive via the image or the workspace — workspace discovery already covers `.coyote/mcp.json` etc., `src/config/paths.rs:207-220`).
- Credentials unchanged: the existing `proxy-managed` entries (`assets/sbx-kit/spec.yaml:211-239`).
Parameterizing by env (rather than hardcoding an agent name) means one profile serves any supervisor and this repo carries no supervisor-specific content.
## What already works (verified, no changes)
- Prompt intake: trailing positional + piped stdin (`src/cli/mod.rs:47-48`, `cli.text()` `:264-302`).
- Vault in sandboxes: disabled under `IS_SANDBOX`; credentials via the sbx proxy (`src/vault/mod.rs:97-123`; `src/sandbox/mod.rs:206-248,339-363`).
- Workspace MCP discovery: `.coyote/mcp.json``.coyote/.mcp.json``.mcp.json` (`src/config/paths.rs:207-220`) — supervisors can drop MCP wiring into a workspace with zero changes here.
## Sequencing & effort
| Step | What | Size |
|---|---|---|
| 1 | `--headless` | ~50 LOC, easy |
| 2 | kit headless profile | YAML, easy |
| 3 | ACP dep evaluation (crate vs hand-rolled) | ½ day |
| 4-7 | `--acp-server` in four increments (skeleton+purity → sessions/prompt → load/cancel → request_permission) | 600-900 LOC, medium |
Every hard sub-problem maps onto machinery built for another feature: session resume, sub-agent escalation, abort signal, SseHandler streaming, silent rendering. The new code is protocol framing plus glue.
+15
View File
@@ -230,6 +230,10 @@ pub struct Cli {
/// Start the sandbox with a clean slate. No copied config or tokens; LLM credentials injected via sbx proxy /// Start the sandbox with a clean slate. No copied config or tokens; LLM credentials injected via sbx proxy
#[arg(long, requires = "sandbox", help_heading = "Sandbox")] #[arg(long, requires = "sandbox", help_heading = "Sandbox")]
pub fresh: bool, pub fresh: bool,
/// Declare that no human is present. All user-interaction tools return structured JSON instead of
/// prompting. Implies --dangerously-skip-permissions. Incompatible with REPL mode (requires a prompt).
#[arg(long, help_heading = "Sandbox")]
pub headless: bool,
/// Display information /// Display information
#[arg(long, help_heading = "Diagnostics & Tools")] #[arg(long, help_heading = "Diagnostics & Tools")]
pub info: bool, pub info: bool,
@@ -490,6 +494,17 @@ mod tests {
assert!(!cli.dangerously_skip_permissions); assert!(!cli.dangerously_skip_permissions);
} }
#[test]
fn parse_headless_flag() {
let cli = parse(&["--headless", "do something"]);
assert!(cli.headless);
}
#[test]
fn parse_headless_default_off() {
assert!(!parse(&[]).headless);
}
#[test] #[test]
fn parse_sync_models_flag() { fn parse_sync_models_flag() {
let cli = parse(&["--sync-models"]); let cli = parse(&["--sync-models"]);
+47
View File
@@ -1,11 +1,13 @@
use super::{FunctionDeclaration, JsonSchema}; use super::{FunctionDeclaration, JsonSchema};
use crate::config::RequestContext; use crate::config::RequestContext;
use crate::supervisor::escalation::{EscalationRequest, new_escalation_id}; use crate::supervisor::escalation::{EscalationRequest, new_escalation_id};
use crate::utils::HEADLESS;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use indexmap::IndexMap; use indexmap::IndexMap;
use inquire::{Confirm, MultiSelect, Select, Text}; use inquire::{Confirm, MultiSelect, Select, Text};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::sync::atomic::Ordering;
use std::time::Duration; use std::time::Duration;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -136,6 +138,10 @@ pub async fn handle_user_tool(
.strip_prefix(USER_FUNCTION_PREFIX) .strip_prefix(USER_FUNCTION_PREFIX)
.unwrap_or(cmd_name); .unwrap_or(cmd_name);
if HEADLESS.load(Ordering::SeqCst) {
return Ok(handle_headless(action, args));
}
let depth = ctx.current_depth; let depth = ctx.current_depth;
if depth == 0 { if depth == 0 {
@@ -145,6 +151,22 @@ pub async fn handle_user_tool(
} }
} }
fn handle_headless(action: &str, args: &Value) -> Value {
let question = args.get("question").and_then(Value::as_str).unwrap_or("");
let options: Vec<Value> = args
.get("options")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
json!({
"needs_human": true,
"action": action,
"question": question,
"options": options,
"guidance": "No human is present. Apply a sensible default or abort the task.",
})
}
fn handle_direct(action: &str, args: &Value) -> Result<Value> { fn handle_direct(action: &str, args: &Value) -> Result<Value> {
match action { match action {
"select" => handle_direct_ask(args), "select" => handle_direct_ask(args),
@@ -271,6 +293,31 @@ async fn handle_escalated(ctx: &RequestContext, action: &str, args: &Value) -> R
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headless_select_returns_structured_json() {
let args = json!({"question": "pick one", "options": ["a", "b"]});
let v = handle_headless("select", &args);
assert_eq!(v["needs_human"], true);
assert_eq!(v["action"], "select");
assert_eq!(v["question"], "pick one");
assert_eq!(v["options"], json!(["a", "b"]));
assert!(v["guidance"].is_string());
}
#[test]
fn headless_confirm_returns_empty_options_when_absent() {
let args = json!({"question": "yes or no?"});
let v = handle_headless("confirm", &args);
assert_eq!(v["needs_human"], true);
assert_eq!(v["action"], "confirm");
assert_eq!(v["options"], json!([]));
}
}
fn parse_options(args: &Value) -> Result<Vec<String>> { fn parse_options(args: &Value) -> Result<Vec<String>> {
let raw = args let raw = args
.get("options") .get("options")
+16 -1
View File
@@ -24,7 +24,7 @@ use crate::client::{
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME; use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{ use crate::config::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope, Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
install_builtins, list_agents, load_env_file, macro_execute, sync_models, install_builtins, list_agents, load_env_file, macro_execute, sync_models,
}; };
use crate::config::{memory, paths}; use crate::config::{memory, paths};
@@ -49,6 +49,7 @@ use log4rs::config::{Appender, Logger, Root};
use log4rs::encode::pattern::PatternEncoder; use log4rs::encode::pattern::PatternEncoder;
use oauth::OAuthProvider; use oauth::OAuthProvider;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::{env, fs, process, sync::Arc}; use std::{env, fs, process, sync::Arc};
#[tokio::main] #[tokio::main]
@@ -81,6 +82,16 @@ async fn main() -> Result<()> {
WorkingMode::Cmd WorkingMode::Cmd
}; };
if cli.headless {
if text.is_none() && cli.file.is_empty() {
bail!("--headless requires a prompt argument; REPL mode is not supported");
}
unsafe {
env::set_var("AUTO_CONFIRM", "true");
}
HEADLESS.store(true, Ordering::SeqCst);
}
let info_flag = cli.info let info_flag = cli.info
|| cli.sync_models || cli.sync_models
|| cli.list_models || cli.list_models
@@ -218,6 +229,10 @@ async fn main() -> Result<()> {
} }
} }
if cli.headless {
ctx.render_mode = RenderMode::Silent;
}
if let Err(err) = run(ctx, cli, text, abort_signal).await { if let Err(err) = run(ctx, cli, text, abort_signal).await {
render_error(err); render_error(err);
process::exit(1); process::exit(1);
+2
View File
@@ -33,6 +33,7 @@ use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use is_terminal::IsTerminal; use is_terminal::IsTerminal;
use nu_ansi_term::Color; use nu_ansi_term::Color;
use std::borrow::Cow; use std::borrow::Cow;
use std::sync::atomic::AtomicBool;
use std::sync::{LazyLock, OnceLock}; use std::sync::{LazyLock, OnceLock};
use std::{cmp, env, path::PathBuf, process}; use std::{cmp, env, path::PathBuf, process};
use syntect::highlighting::{Highlighter, Theme}; use syntect::highlighting::{Highlighter, Theme};
@@ -43,6 +44,7 @@ pub static CODE_BLOCK_RE: LazyLock<Regex> =
pub static THINK_TAG_RE: LazyLock<Regex> = pub static THINK_TAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)^\s*<think>.*?</think>(\s*|$)").unwrap()); LazyLock::new(|| Regex::new(r"(?s)^\s*<think>.*?</think>(\s*|$)").unwrap());
pub static IS_STDOUT_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal()); pub static IS_STDOUT_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal());
pub static HEADLESS: AtomicBool = AtomicBool::new(false);
pub static NO_COLOR: LazyLock<bool> = LazyLock::new(|| { pub static NO_COLOR: LazyLock<bool> = LazyLock::new(|| {
env::var("NO_COLOR") env::var("NO_COLOR")
.ok() .ok()