Compare commits

..
13 Commits
Author SHA1 Message Date
Dark-Alex-17 2128390f99 fmt: applied formatting
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-27 19:26:29 -06:00
Dark-Alex-17 d008de1848 fix: restore stdout output for standalone --headless mode
RenderMode::Silent was incorrectly applied to --headless in addition to
--acp-server. Standalone headless should still display the LLM response;
only ACP mode requires stdout purity for JSON-RPC. The acp server's
run_prompt_turn already sets Silent before each prompt call.
2026-07-27 19:22:51 -06:00
Dark-Alex-17 d79787bf96 fix: suppress tool-call display in headless mode; initialize session on session/new 2026-07-27 19:14:28 -06:00
Dark-Alex-17 577c51b62f fix: skip stdin drain and set silent render mode when --acp-server is active 2026-07-27 18:59:09 -06:00
Dark-Alex-17 d462b09f80 feat: add headless profile to sbx-kit spec 2026-07-27 18:03:35 -06:00
Dark-Alex-17 b711e4983b feat: implement ACP user-interaction to request_permission bridge 2026-07-27 18:00:07 -06:00
Dark-Alex-17 6ae3efb06c feat: implement ACP session/load and session/cancel 2026-07-27 17:52:49 -06:00
Dark-Alex-17 02dd14394b feat: implement ACP session/prompt 2026-07-27 17:48:31 -06:00
Dark-Alex-17 f11d4ca760 feat: add ACP server skeleton with stdout-purity test 2026-07-27 17:31:47 -06:00
Dark-Alex-17 f1415067f2 feat: add --headless flag for unattended operation 2026-07-27 17:17:56 -06:00
Dark-Alex-17 af5c34fde5 Merge branch 'main' of github.com:Dark-Alex-17/coyote 2026-07-27 15:05:25 -06:00
Dark-Alex-17 2ffa278f2d test: testing potential nerdbox regression fix for coyote sandbox mode 2026-07-27 15:04:41 -06:00
Dark-Alex-17 f4cbee9611 docs: remove comment in spec.yaml about copying in coyote password file 2026-07-27 10:50:40 -06:00
11 changed files with 789 additions and 13 deletions
+24 -1
View File
@@ -1,5 +1,5 @@
ARG COYOTE_VERSION ARG COYOTE_VERSION
FROM docker/sandbox-templates:shell-docker FROM docker/sandbox-templates:shell-docker AS build
ARG COYOTE_VERSION ARG COYOTE_VERSION
ARG TARGETARCH ARG TARGETARCH
@@ -67,6 +67,29 @@ RUN set -euo pipefail; \
chown 1000:1000 /home/agent/.cargo/bin/coyote; \ chown 1000:1000 /home/agent/.cargo/bin/coyote; \
rm -rf "$TMPDIR" rm -rf "$TMPDIR"
FROM scratch
ARG COYOTE_VERSION
COPY --from=build / /
ENV PATH="/home/agent/.cargo/bin:/home/agent/.local/bin:/usr/local/share/npm-global/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
NPM_CONFIG_PREFIX="/usr/local/share/npm-global" \
NO_PROXY="localhost,127.0.0.1,::1,172.17.0.0/16" \
no_proxy="localhost,127.0.0.1,::1,172.17.0.0/16" \
BASH_ENV="/etc/sandbox-persistent.sh"
LABEL com.docker.sandboxes="templates" \
com.docker.sandboxes.base="ubuntu:questing" \
com.docker.sandboxes.flavor="shell-docker" \
com.docker.sandboxes.start-docker="true" \
org.opencontainers.image.title="coyote" \
org.opencontainers.image.description="An all-in-one, batteries-included LLM CLI tool: Shell Assistant, CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros." \
org.opencontainers.image.source="https://github.com/Dark-Alex-17/coyote" \
org.opencontainers.image.version="${COYOTE_VERSION}"
WORKDIR /home/agent/workspace
USER 1000 USER 1000
ENTRYPOINT ["coyote"] ENTRYPOINT ["coyote"]
+11 -1
View File
@@ -3,7 +3,6 @@
# Setup (paths use $HOME so commands work in bash/zsh/PowerShell/Git Bash): # Setup (paths use $HOME so commands work in bash/zsh/PowerShell/Git Bash):
# sbx create --kit ./sbx-kit/ coyote --name testing . # sbx create --kit ./sbx-kit/ coyote --name testing .
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/ # sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
# sbx cp $HOME/.coyote_password testing:/home/agent/
# sbx run testing --kit ./sbx-kit/ # sbx run testing --kit ./sbx-kit/
schemaVersion: '1' schemaVersion: '1'
kind: sandbox kind: sandbox
@@ -250,6 +249,17 @@ commands:
background: false background: false
description: Bootstrap Coyote config directory on first sandbox start description: Bootstrap Coyote config directory on first sandbox start
profiles:
headless:
description: >
Unattended mode for scripted use. Set COYOTE_PROMPT_FILE to the path of
a prompt file, then launch: sbx run coyote --profile headless
entrypoint:
run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote --headless -f "${COYOTE_PROMPT_FILE}"']
environment:
variables:
COYOTE_LOG_LEVEL: WARN
agentContext: | agentContext: |
## Sandbox environment ## Sandbox environment
+4
View File
@@ -0,0 +1,4 @@
mod server;
mod types;
pub use server::run_acp_server;
+540
View File
@@ -0,0 +1,540 @@
use super::types::{METHOD_NOT_FOUND, PARSE_ERROR, Request, Response};
use crate::client::call_chat_completions_streaming;
use crate::config::{Input, RenderMode, RequestContext};
use crate::utils;
use crate::utils::AbortSignal;
use anyhow::Result;
use serde_json::{Value, json};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
pub(crate) struct AcpServerState {
ctx: Option<RequestContext>,
abort: AbortSignal,
session_active: bool,
}
pub async fn run_acp_server(ctx: RequestContext, abort: AbortSignal) -> Result<()> {
let state = AcpServerState {
ctx: Some(ctx),
abort,
session_active: false,
};
run_acp_server_with_state(tokio::io::stdin(), tokio::io::stdout(), state).await
}
#[cfg(test)]
pub(crate) async fn run_acp_server_on<R, W>(reader: R, writer: W) -> Result<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
use crate::utils::{create_abort_signal, drain_acp_permissions};
drain_acp_permissions();
let state = AcpServerState {
ctx: None,
abort: create_abort_signal(),
session_active: false,
};
run_acp_server_with_state(reader, writer, state).await
}
async fn run_acp_server_with_state<R, W>(
reader: R,
mut writer: W,
mut state: AcpServerState,
) -> Result<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let reader = BufReader::new(reader);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
if let Some(response) = dispatch(&line, &mut state).await {
for params in utils::drain_acp_permissions() {
emit_notification(&mut writer, "session/request_permission", params).await?;
}
emit(&mut writer, &response).await?;
}
}
Ok(())
}
async fn dispatch(raw: &str, state: &mut AcpServerState) -> Option<Response> {
let req: Request = match serde_json::from_str(raw) {
Ok(r) => r,
Err(_) => return Some(Response::err(None, PARSE_ERROR, "Parse error")),
};
// session/cancel is a notification. Handle it regardless of whether an id is present.
if req.method == "session/cancel" {
handle_session_cancel(state);
return if req.id.is_some() {
Some(Response::ok(req.id, json!({})))
} else {
None
};
}
req.id.as_ref()?;
Some(match req.method.as_str() {
"initialize" => handle_initialize(req),
"session/new" => handle_session_new(req, state).await,
"session/load" => handle_session_load(req, state).await,
"session/prompt" => handle_session_prompt(req, state).await,
_ => Response::err(
req.id,
METHOD_NOT_FOUND,
format!("Method not found: {}", req.method),
),
})
}
fn handle_initialize(req: Request) -> Response {
Response::ok(
req.id,
json!({
"name": "coyote",
"version": env!("CARGO_PKG_VERSION"),
"protocolVersion": "1",
}),
)
}
async fn handle_session_new(req: Request, state: &mut AcpServerState) -> Response {
if state.session_active {
return Response::err(
req.id,
-32000,
"Session already active; this server supports one session per process",
);
}
let ctx = match state.ctx.as_mut() {
Some(c) => c,
None => {
state.session_active = true;
return Response::ok(req.id, json!({ "sessionId": "default" }));
}
};
let app = Arc::clone(&ctx.app.config);
let abort = state.abort.clone();
match ctx.use_session(app.as_ref(), None, abort).await {
Ok(_) => {
state.session_active = true;
ctx.render_mode = RenderMode::Silent;
Response::ok(req.id, json!({ "sessionId": "default" }))
}
Err(e) => Response::err(req.id, -32000, format!("Failed to create session: {e}")),
}
}
async fn handle_session_prompt(req: Request, state: &mut AcpServerState) -> Response {
if !state.session_active {
return Response::err(req.id, -32000, "No active session; call session/new first");
}
let text = match req
.params
.as_ref()
.and_then(|p| p.get("text"))
.and_then(Value::as_str)
{
Some(t) => t.to_string(),
None => return Response::err(req.id, -32602, "Missing params.text"),
};
let ctx = match state.ctx.as_mut() {
Some(c) => c,
None => return Response::err(req.id, -32000, "Server not configured with a context"),
};
let abort = state.abort.clone();
match run_prompt_turn(ctx, &text, abort).await {
Ok(output) => Response::ok(
req.id,
json!({ "output": output, "stopReason": "end_turn" }),
),
Err(e) => Response::err(req.id, -32000, format!("Prompt failed: {e}")),
}
}
async fn run_prompt_turn(
ctx: &mut RequestContext,
text: &str,
abort: AbortSignal,
) -> Result<String> {
ctx.render_mode = RenderMode::Silent;
let input = Input::from_str(ctx, text, None)?;
ctx.before_chat_completion(&input)?;
let client = input.create_client()?;
let (output, tool_results) =
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort).await?;
let app = Arc::clone(&ctx.app.config);
ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?;
Ok(output)
}
fn handle_session_cancel(state: &mut AcpServerState) {
state.abort.set_ctrlc();
}
async fn handle_session_load(req: Request, state: &mut AcpServerState) -> Response {
if state.session_active {
return Response::err(req.id, -32000, "Session already active");
}
let session_name = match req
.params
.as_ref()
.and_then(|p| p.get("sessionId"))
.and_then(Value::as_str)
{
Some(n) => n.to_string(),
None => return Response::err(req.id, -32602, "Missing params.sessionId"),
};
let ctx = match state.ctx.as_mut() {
Some(c) => c,
None => return Response::err(req.id, -32000, "Server not configured with a context"),
};
let app = Arc::clone(&ctx.app.config);
let abort = state.abort.clone();
match ctx
.use_session(app.as_ref(), Some(&session_name), abort)
.await
{
Ok(_) => {
state.session_active = true;
ctx.render_mode = RenderMode::Silent;
Response::ok(req.id, json!({ "sessionId": session_name }))
}
Err(e) => Response::err(req.id, -32000, format!("Failed to load session: {e}")),
}
}
async fn emit<W: AsyncWrite + Unpin>(writer: &mut W, response: &Response) -> Result<()> {
let mut line = serde_json::to_string(response)?;
line.push('\n');
writer.write_all(line.as_bytes()).await?;
writer.flush().await?;
Ok(())
}
async fn emit_notification<W: AsyncWrite + Unpin>(
writer: &mut W,
method: &str,
params: Value,
) -> Result<()> {
let frame = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let mut line = serde_json::to_string(&frame)?;
line.push('\n');
writer.write_all(line.as_bytes()).await?;
writer.flush().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::str;
#[tokio::test]
async fn all_stdout_is_valid_json_rpc() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"name":"test","version":"0.1.0"}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
for line in output.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
let s = str::from_utf8(line).expect("non-UTF8 in ACP stdout");
let _: Value = serde_json::from_str(s)
.unwrap_or_else(|_| panic!("ACP stdout not valid JSON: {s}"));
}
}
#[tokio::test]
async fn unknown_method_returns_method_not_found() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":2,"method":"nonexistent","params":{}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["error"]["code"], METHOD_NOT_FOUND);
assert_eq!(v["id"], 2);
}
#[tokio::test]
async fn invalid_json_returns_parse_error() {
let input = "not json\n";
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["error"]["code"], PARSE_ERROR);
}
#[tokio::test]
async fn notification_without_id_produces_no_output() {
let input = concat!(
r#"{"jsonrpc":"2.0","method":"session/cancel","params":{}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
assert!(output.is_empty());
}
#[tokio::test]
async fn initialize_returns_server_info() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"name":"test","version":"0.1.0"}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["id"], 1);
assert_eq!(v["result"]["name"], "coyote");
assert!(v["result"]["version"].is_string());
}
#[tokio::test]
async fn session_new_returns_session_id() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":10,"method":"session/new","params":{}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["id"], 10);
assert_eq!(v["result"]["sessionId"], "default");
}
#[tokio::test]
async fn session_new_twice_errors() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}"#,
"\n",
r#"{"jsonrpc":"2.0","id":2,"method":"session/new","params":{}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let mut lines = s.lines().filter(|l| !l.is_empty());
let first: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
let second: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert!(first["result"]["sessionId"].is_string());
assert_eq!(second["error"]["code"], -32000);
}
#[tokio::test]
async fn session_prompt_without_session_errors() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"text":"hello"}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["id"], 3);
assert_eq!(v["error"]["code"], -32000);
}
#[tokio::test]
async fn session_prompt_with_no_context_returns_error() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}"#,
"\n",
r#"{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"text":"hello"}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let mut lines = s.lines().filter(|l| !l.is_empty());
let first: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
let second: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(first["result"]["sessionId"], "default");
assert_eq!(second["id"], 2);
assert!(
second["error"].is_object(),
"expected error response when no ctx"
);
}
#[tokio::test]
async fn session_cancel_notification_produces_no_output() {
let input = concat!(r#"{"jsonrpc":"2.0","method":"session/cancel"}"#, "\n",);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
assert!(output.is_empty());
}
#[tokio::test]
async fn session_cancel_request_returns_ok() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":99,"method":"session/cancel"}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["id"], 99);
assert!(v["result"].is_object());
}
#[tokio::test]
async fn session_load_missing_session_id_errors() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":5,"method":"session/load","params":{}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["id"], 5);
assert_eq!(v["error"]["code"], -32602);
}
#[tokio::test]
async fn session_load_after_session_new_errors() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}"#,
"\n",
r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"abc"}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let mut lines = s.lines().filter(|l| !l.is_empty());
let _first: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
let second: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(second["id"], 2);
assert_eq!(second["error"]["code"], -32000);
}
#[tokio::test]
async fn session_load_with_no_context_returns_error() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":6,"method":"session/load","params":{"sessionId":"my-session"}}"#,
"\n",
);
let mut output = Vec::new();
run_acp_server_on(input.as_bytes(), &mut output)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["id"], 6);
assert!(v["error"].is_object());
}
#[tokio::test]
async fn emit_notification_produces_valid_json_rpc_frame() {
let mut output = Vec::new();
emit_notification(
&mut output,
"session/request_permission",
json!({"action": "confirm", "question": "Proceed?"}),
)
.await
.unwrap();
let s = String::from_utf8(output).unwrap();
let v: Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["method"], "session/request_permission");
assert!(v["params"]["action"].is_string());
assert!(!v.as_object().unwrap().contains_key("id"));
}
}
+59
View File
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const PARSE_ERROR: i32 = -32700;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct Request {
pub jsonrpc: String,
pub id: Option<Value>,
pub method: String,
pub params: Option<Value>,
}
#[derive(Debug, Serialize)]
pub struct Response {
pub jsonrpc: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<Value>,
#[serde(flatten)]
pub body: ResponseBody,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum ResponseBody {
Ok { result: Value },
Err { error: RpcError },
}
#[derive(Debug, Serialize)]
pub struct RpcError {
pub code: i32,
pub message: String,
}
impl Response {
pub fn ok(id: Option<Value>, result: Value) -> Self {
Self {
jsonrpc: "2.0",
id,
body: ResponseBody::Ok { result },
}
}
pub fn err(id: Option<Value>, code: i32, message: impl Into<String>) -> Self {
Self {
jsonrpc: "2.0",
id,
body: ResponseBody::Err {
error: RpcError {
code,
message: message.into(),
},
},
}
}
}
+30
View File
@@ -230,6 +230,14 @@ 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,
/// Run as an ACP agent server over stdio (JSON-RPC 2.0). Every stdout byte must be valid JSON-RPC.
/// Implies --headless. Single session per process.
#[arg(long, help_heading = "Sandbox")]
pub acp_server: 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 +498,28 @@ 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]
fn parse_acp_server_flag() {
let cli = parse(&["--acp-server"]);
assert!(cli.acp_server);
}
#[test]
fn parse_acp_server_default_off() {
assert!(!parse(&[]).acp_server);
}
#[test] #[test]
fn parse_sync_models_flag() { fn parse_sync_models_flag() {
let cli = parse(&["--sync-models"]); let cli = parse(&["--sync-models"]);
+2 -2
View File
@@ -502,7 +502,7 @@ pub async fn call_chat_completions_streaming(
let (text, tool_calls, thinking) = handler.take(); let (text, tool_calls, thinking) = handler.take();
match send_ret { match send_ret {
Ok(_) => { Ok(_) => {
if !text.is_empty() && !text.ends_with('\n') { if !silent && !text.is_empty() && !text.ends_with('\n') {
println!(); println!();
} }
let mut tool_results = eval_tool_calls(ctx, tool_calls).await?; let mut tool_results = eval_tool_calls(ctx, tool_calls).await?;
@@ -515,7 +515,7 @@ pub async fn call_chat_completions_streaming(
Ok((text, tool_results)) Ok((text, tool_results))
} }
Err(err) => { Err(err) => {
if !text.is_empty() { if !silent && !text.is_empty() {
println!(); println!();
} }
Err(err) Err(err)
+2 -1
View File
@@ -30,6 +30,7 @@ use std::collections::VecDeque;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs::File; use std::fs::File;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::sync::atomic::Ordering;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
env, fs, io, env, fs, io,
@@ -1090,7 +1091,7 @@ impl ToolCall {
cmd_args.push(json_data.to_string()); cmd_args.push(json_data.to_string());
if *IS_STDOUT_TERMINAL && current_depth == 0 { if *IS_STDOUT_TERMINAL && current_depth == 0 && !HEADLESS.load(Ordering::SeqCst) {
println!("{}", format_call_log(&cmd_name, &cmd_args, &json_data)); println!("{}", format_call_log(&cmd_name, &cmd_args, &json_data));
} }
+59
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::{ACP_SERVER, HEADLESS, queue_acp_permission};
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,21 @@ 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 ACP_SERVER.load(Ordering::SeqCst) {
let result = handle_headless(action, args);
queue_acp_permission(json!({
"action": action,
"question": result["question"],
"options": result["options"],
}));
return Ok(result);
}
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 +162,23 @@ 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 +305,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")
+37 -7
View File
@@ -1,3 +1,4 @@
mod acp;
mod cli; mod cli;
mod client; mod client;
mod config; mod config;
@@ -24,7 +25,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};
@@ -40,7 +41,7 @@ use clap_complete::CompleteEnv;
use client::ClientConfig; use client::ClientConfig;
use inquire::{Select, Text, set_global_render_config}; use inquire::{Select, Text, set_global_render_config};
use log::{LevelFilter, warn}; use log::{LevelFilter, warn};
use log4rs::append::console::ConsoleAppender; use log4rs::append::console::{ConsoleAppender, Target};
use log4rs::append::rolling_file::RollingFileAppender; use log4rs::append::rolling_file::RollingFileAppender;
use log4rs::append::rolling_file::policy::compound::CompoundPolicy; use log4rs::append::rolling_file::policy::compound::CompoundPolicy;
use log4rs::append::rolling_file::policy::compound::roll::fixed_window::FixedWindowRoller; use log4rs::append::rolling_file::policy::compound::roll::fixed_window::FixedWindowRoller;
@@ -49,6 +50,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]
@@ -74,13 +76,29 @@ async fn main() -> Result<()> {
return Ok(()); return Ok(());
} }
let text = cli.text()?; let text = if cli.acp_server { None } else { cli.text()? };
let working_mode = if text.is_none() && cli.file.is_empty() { let working_mode = if !cli.acp_server && text.is_none() && cli.file.is_empty() {
WorkingMode::Repl WorkingMode::Repl
} else { } else {
WorkingMode::Cmd WorkingMode::Cmd
}; };
if cli.headless || cli.acp_server {
if cli.headless && !cli.acp_server && 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);
if cli.acp_server {
ACP_SERVER.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
@@ -96,7 +114,7 @@ async fn main() -> Result<()> {
|| cli.delete_secret.is_some() || cli.delete_secret.is_some()
|| cli.list_secrets; || cli.list_secrets;
let log_path = setup_logger()?; let log_path = setup_logger(cli.acp_server)?;
if let Some(version) = &cli.update { if let Some(version) = &cli.update {
let version = version.clone(); let version = version.clone();
@@ -218,6 +236,14 @@ async fn main() -> Result<()> {
} }
} }
if cli.acp_server {
ctx.render_mode = RenderMode::Silent;
}
if cli.acp_server {
return acp::run_acp_server(ctx, abort_signal).await;
}
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);
@@ -679,7 +705,7 @@ async fn create_input(
Ok(input) Ok(input)
} }
fn setup_logger() -> Result<Option<PathBuf>> { fn setup_logger(acp_mode: bool) -> Result<Option<PathBuf>> {
let (log_level, log_path) = paths::log_config()?; let (log_level, log_path) = paths::log_config()?;
if log_level == LevelFilter::Off { if log_level == LevelFilter::Off {
return Ok(None); return Ok(None);
@@ -690,7 +716,11 @@ fn setup_logger() -> Result<Option<PathBuf>> {
let log_filter = env::var(get_env_name("log_filter")).ok(); let log_filter = env::var(get_env_name("log_filter")).ok();
match log_path.clone() { match log_path.clone() {
None => { None => {
let console_appender = ConsoleAppender::builder().encoder(encoder).build(); let mut builder = ConsoleAppender::builder().encoder(encoder);
if acp_mode {
builder = builder.target(Target::Stderr);
}
let console_appender = builder.build();
log4rs::init_config(init_console_logger(log_level, log_filter, console_appender))?; log4rs::init_config(init_console_logger(log_level, log_filter, console_appender))?;
} }
Some(path) => { Some(path) => {
+21 -1
View File
@@ -32,8 +32,11 @@ use fancy_regex::Regex;
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; 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 serde_json::Value;
use std::borrow::Cow; use std::borrow::Cow;
use std::sync::{LazyLock, OnceLock}; use std::collections::VecDeque;
use std::sync::atomic::AtomicBool;
use std::sync::{LazyLock, Mutex, 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};
use syntect::parsing::Scope; use syntect::parsing::Scope;
@@ -43,6 +46,23 @@ 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 ACP_SERVER: AtomicBool = AtomicBool::new(false);
static ACP_PERMISSION_QUEUE: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
pub fn queue_acp_permission(notification: Value) {
if let Ok(mut q) = ACP_PERMISSION_QUEUE.lock() {
q.push_back(notification);
}
}
pub fn drain_acp_permissions() -> Vec<Value> {
ACP_PERMISSION_QUEUE
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default()
}
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()