Compare commits
13
Commits
e95fd1e06e
...
2128390f99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2128390f99
|
||
|
|
d008de1848
|
||
|
|
d79787bf96
|
||
|
|
577c51b62f
|
||
|
|
d462b09f80
|
||
|
|
b711e4983b
|
||
|
|
6ae3efb06c
|
||
|
|
02dd14394b
|
||
|
|
f11d4ca760
|
||
|
|
f1415067f2
|
||
|
|
af5c34fde5
|
||
|
|
2ffa278f2d | ||
|
|
f4cbee9611
|
+24
-1
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
mod server;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use server::run_acp_server;
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user