use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use tokio::sync::oneshot;
use crate::agent::framing::LineFramer;
use crate::agent::protocol::{
AgentRecord, DialogMethod, DialogRequest, StreamingBehavior, Usage, as_dialog_request,
is_fire_and_forget, message_role, message_text, starts_thinking, thinking_ended, tool_target,
usage_of,
};
use crate::log::Logger;
use crate::log::fields;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentState {
Starting,
Ready,
Working,
Ended,
}
pub trait AgentProcess: Send + Sync + 'static {
fn write(&self, bytes: &[u8]) -> std::io::Result<()>;
fn read_stdout<'a>(
&'a self,
buf: &'a mut [u8],
) -> Pin<Box<dyn Future<Output = std::io::Result<usize>> + Send + 'a>>;
fn read_stderr<'a>(
&'a self,
buf: &'a mut [u8],
) -> Pin<Box<dyn Future<Output = std::io::Result<usize>> + Send + 'a>>;
fn exited(&self) -> Pin<Box<dyn Future<Output = Option<i32>> + Send>>;
}
pub type Callback<A = ()> = Option<Box<dyn Fn(A) + Send + Sync>>;
#[expect(
clippy::struct_field_names,
reason = "the prefix is the protocol's own vocabulary"
)]
#[derive(Default)]
pub struct AgentHandlers {
pub on_turn_start: Callback,
pub on_assistant_text: Callback<String>,
pub on_turn_settled: Callback<(bool, Option<String>)>,
pub on_tool_start: Callback<(String, String, Option<String>)>,
pub on_tool_end: Callback<(String, String, bool, String)>,
pub on_thinking: Callback,
pub on_thought: Callback<String>,
pub on_usage: Callback<Usage>,
pub on_error: Callback<String>,
pub on_command_rejected: Callback<(String, String)>,
pub on_retry: Callback<String>,
pub on_dialog: Callback<DialogRequest>,
pub on_dialog_timeout: Callback<DialogRequest>,
pub on_unsupported_dialog: Callback<String>,
pub on_exit: Callback<(i64, bool)>,
pub on_protocol_violation: Callback<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnswerOutcome {
Accepted,
Unrecognized,
Unknown,
}
const STDERR_KEPT: usize = 2_000;
const AFFIRMATIVE: [&str; 5] = ["yes", "y", "true", "ok", "confirm"];
const NEGATIVE: [&str; 5] = ["no", "n", "false", "cancel", "deny"];
enum DialogReply {
Value(String),
Confirmed(bool),
}
pub(crate) struct PendingDialog {
pub(crate) request: DialogRequest,
pub(crate) timer: tokio::task::JoinHandle<()>,
}
pub(crate) struct ClientState {
pub(crate) framer: LineFramer,
pub(crate) lifecycle: AgentState,
pub(crate) exit_reported: bool,
pub(crate) thinking_reported: bool,
pub(crate) last_words: String,
pub(crate) produced_text: bool,
pub(crate) turn_failure: Option<String>,
pub(crate) context_window: Option<f64>,
pub(crate) dialogs: BTreeMap<String, PendingDialog>,
pub(crate) requests: BTreeMap<String, oneshot::Sender<AgentRecord>>,
pub(crate) next_request_id: u64,
}
fn build_response(request: &DialogRequest, reply: &str) -> Option<DialogReply> {
let trimmed = reply.trim();
if request.method == DialogMethod::Confirm {
let lowered = trimmed.to_lowercase();
if AFFIRMATIVE.contains(&lowered.as_str()) {
return Some(DialogReply::Confirmed(true));
}
if NEGATIVE.contains(&lowered.as_str()) {
return Some(DialogReply::Confirmed(false));
}
return None;
}
if request.method == DialogMethod::Select {
let options = request.options.as_deref().unwrap_or(&[]);
if let Ok(index) = trimmed.parse::<usize>()
&& (1..=options.len()).contains(&index)
{
return Some(DialogReply::Value(options[index - 1].clone()));
}
let matched = options
.iter()
.find(|option| option.to_lowercase() == trimmed.to_lowercase())?;
return Some(DialogReply::Value(matched.clone()));
}
if trimmed.is_empty() {
return None;
}
Some(DialogReply::Value(trimmed.to_owned()))
}
fn message_failure(message: Option<&Value>) -> Option<String> {
let message = message?;
if message.get("stopReason").and_then(Value::as_str) != Some("error") {
return None;
}
match message.get("error") {
Some(Value::String(error)) if !error.trim().is_empty() => Some(error.trim().to_owned()),
Some(error @ Value::Object(_)) => {
let from_field = error
.get("message")
.and_then(Value::as_str)
.filter(|detail| !detail.trim().is_empty())
.map(str::trim)
.map(str::to_owned);
Some(from_field.unwrap_or_else(|| "the model provider did not answer".to_owned()))
}
_ => Some("the model provider did not answer".to_owned()),
}
}
fn detail_of(record: &Value) -> String {
for key in ["error", "message", "reason"] {
if let Some(Value::String(detail)) = record.get(key)
&& !detail.is_empty()
{
return detail.clone();
}
}
record
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned()
}
struct Shared {
process: Arc<dyn AgentProcess>,
state: Arc<Mutex<ClientState>>,
handlers: Arc<AgentHandlers>,
log: Logger,
dialog_timeout_ms: u64,
}
#[derive(Clone)]
pub struct AgentClient {
inner: Arc<Shared>,
}
impl AgentClient {
pub fn new(
process: Arc<dyn AgentProcess>,
handlers: AgentHandlers,
log: Logger,
dialog_timeout_ms: u64,
max_record_bytes: Option<usize>,
) -> Self {
Self {
inner: Arc::new(Shared {
process,
state: Arc::new(Mutex::new(ClientState {
framer: LineFramer::new(max_record_bytes.unwrap_or(8 * 1024 * 1024)),
lifecycle: AgentState::Starting,
exit_reported: false,
thinking_reported: false,
last_words: String::new(),
produced_text: false,
turn_failure: None,
context_window: None,
dialogs: BTreeMap::new(),
requests: BTreeMap::new(),
next_request_id: 1,
})),
handlers: Arc::new(handlers),
log,
dialog_timeout_ms,
}),
}
}
pub fn dying_words(&self) -> String {
self.inner
.state
.lock()
.expect("the client lock")
.last_words
.clone()
}
#[allow(
dead_code,
reason = "read by this module's tests, which assert on state the daemon never asks for"
)]
pub fn state(&self) -> AgentState {
self.inner.state.lock().expect("the client lock").lifecycle
}
#[allow(
dead_code,
reason = "read by this module's tests, which assert on state the daemon never asks for"
)]
pub fn is_alive(&self) -> bool {
self.state() != AgentState::Ended
}
#[allow(
dead_code,
reason = "read by this module's tests, which assert on state the daemon never asks for"
)]
pub fn is_working(&self) -> bool {
self.state() == AgentState::Working
}
pub fn context_window(&self) -> Option<f64> {
self.inner
.state
.lock()
.expect("the client lock")
.context_window
}
pub async fn run(&self) {
let error_reader = Shared {
process: Arc::clone(&self.inner.process),
state: Arc::clone(&self.inner.state),
handlers: Arc::clone(&self.inner.handlers),
log: self.inner.log.clone(),
dialog_timeout_ms: self.inner.dialog_timeout_ms,
};
let stderr = tokio::spawn(async move { error_reader.read_stderr().await });
self.inner.read_stdout().await;
let _ = stderr.await;
let code = self.inner.process.exited().await.unwrap_or(-1);
self.end(i64::from(code));
}
pub async fn wait_until_ready(&self, timeout_ms: u64) -> Result<AgentRecord, String> {
let answer = self
.request(json!({ "type": "get_state" }), timeout_ms)
.await?;
let window = answer
.get("data")
.and_then(|data| data.get("model"))
.and_then(|model| model.get("contextWindow"))
.and_then(Value::as_f64);
if window.is_some_and(|window| window > 0.0) {
self.inner
.state
.lock()
.expect("the client lock")
.context_window = window;
}
let mut state = self.inner.state.lock().expect("the client lock");
if state.lifecycle == AgentState::Starting {
state.lifecycle = AgentState::Ready;
}
Ok(answer)
}
pub fn prompt(
&self,
message: &str,
images: Option<Vec<Value>>,
behavior: Option<StreamingBehavior>,
) -> bool {
let mut command = json!({ "type": "prompt", "message": message });
if let Some(images) = images {
command["images"] = Value::Array(images);
}
if let Some(behavior) = behavior {
command["streamingBehavior"] = json!(behavior.as_wire());
}
self.send(&command)
}
pub fn steer(&self, message: &str, images: Option<Vec<Value>>) -> bool {
let mut command = json!({ "type": "steer", "message": message });
if let Some(images) = images {
command["images"] = Value::Array(images);
}
self.send(&command)
}
pub fn set_model(&self, provider: &str, model_id: &str) -> bool {
self.send(&json!({ "type": "set_model", "provider": provider, "modelId": model_id }))
}
pub fn abort(&self) -> bool {
self.send(&json!({ "type": "abort" }))
}
pub async fn compact(&self, timeout_ms: u64) -> Result<AgentRecord, String> {
self.request(json!({ "type": "compact" }), timeout_ms).await
}
pub fn respond_to_dialog(&self, response: &Value) -> bool {
self.send(response)
}
pub fn pending_dialog(&self) -> Option<DialogRequest> {
let state = self.inner.state.lock().expect("the client lock");
state
.dialogs
.values()
.next()
.map(|entry| entry.request.clone())
}
pub fn answer_dialog(&self, id: &str, reply: &str) -> AnswerOutcome {
let response = {
let mut state = self.inner.state.lock().expect("the client lock");
let Some(entry) = state.dialogs.get(id) else {
return AnswerOutcome::Unknown;
};
let Some(built) = build_response(&entry.request, reply) else {
return AnswerOutcome::Unrecognized;
};
entry.timer.abort();
state.dialogs.remove(id);
Some(built)
};
let Some(response) = response else {
return AnswerOutcome::Unknown;
};
let answer = match response {
DialogReply::Value(value) => json!({
"type": "extension_ui_response",
"id": id,
"value": value,
}),
DialogReply::Confirmed(confirmed) => json!({
"type": "extension_ui_response",
"id": id,
"confirmed": confirmed,
}),
};
self.send(&answer);
AnswerOutcome::Accepted
}
pub fn cancel_dialogs(&self) {
let ids: Vec<String> = {
let mut state = self.inner.state.lock().expect("the client lock");
let ids: Vec<String> = state.dialogs.keys().cloned().collect();
state.dialogs.clear();
ids
};
for id in ids {
self.respond_to_dialog(&json!({
"type": "extension_ui_response",
"id": id,
"cancelled": true,
}));
}
}
pub async fn request(&self, command: Value, timeout_ms: u64) -> Result<AgentRecord, String> {
let (sender, receiver) = oneshot::channel();
let id = {
let mut state = self.inner.state.lock().expect("the client lock");
let id = format!("rq-{}", state.next_request_id);
state.next_request_id += 1;
state.requests.insert(id.clone(), sender);
id
};
let mut sent = command.clone();
sent["id"] = json!(id);
if !self.send(&sent) {
self.inner
.state
.lock()
.expect("the client lock")
.requests
.remove(&id);
return Err("the agent is not accepting commands".to_owned());
}
let kind = command
.get("type")
.and_then(Value::as_str)
.unwrap_or("command")
.to_owned();
let outcome =
tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), receiver).await;
if let Ok(Ok(record)) = outcome {
return Ok(record);
}
self.inner
.state
.lock()
.expect("the client lock")
.requests
.remove(&id);
Err(format!(
"the agent did not answer {kind} within {timeout_ms}ms"
))
}
fn end(&self, code: i64) {
let during_turn = {
let mut state = self.inner.state.lock().expect("the client lock");
let during_turn = state.lifecycle == AgentState::Working;
state.lifecycle = AgentState::Ended;
if state.exit_reported {
return;
}
state.exit_reported = true;
during_turn
};
self.inner
.fail_pending_requests(&format!("the agent exited with code {code}"));
self.cancel_dialogs();
if let Some(on_exit) = &self.inner.handlers.on_exit {
on_exit((code, during_turn));
}
}
fn send(&self, command: &Value) -> bool {
let lifecycle = self.inner.state.lock().expect("the client lock").lifecycle;
if lifecycle == AgentState::Ended {
self.inner.log.warn(
"dropped a command for an agent that has ended",
&fields([(
"command",
command
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.into(),
)]),
);
return false;
}
let mut line = command.to_string();
line.push('\n');
match self.inner.process.write(line.as_bytes()) {
Ok(()) => true,
Err(error) => {
self.inner.state.lock().expect("the client lock").lifecycle = AgentState::Ended;
self.inner.log.warn(
"writing to the agent failed",
&fields([("detail", error.to_string().into())]),
);
false
}
}
}
}
impl Shared {
async fn read_stdout(&self) {
let mut buffer = vec![0_u8; 65_536];
loop {
let read = match self.process.read_stdout(&mut buffer).await {
Ok(0) | Err(_) => break,
Ok(read) => read,
};
let records = {
let mut state = self.state.lock().expect("the client lock");
match state.framer.push(&buffer[..read]) {
Ok(records) => records,
Err(error) => {
state.lifecycle = AgentState::Ended;
drop(state);
if let Some(on_violation) = &self.handlers.on_protocol_violation {
on_violation(error.to_string());
}
return;
}
}
};
for record in records {
self.dispatch(&record);
}
}
}
async fn read_stderr(&self) {
let mut buffer = vec![0_u8; 16_384];
loop {
let read = match self.process.read_stderr(&mut buffer).await {
Ok(0) | Err(_) => return,
Ok(read) => read,
};
let text = String::from_utf8_lossy(&buffer[..read])
.trim_end()
.to_owned();
if text.is_empty() {
continue;
}
self.log
.warn("agent stderr", &fields([("detail", text.clone().into())]));
let mut state = self.state.lock().expect("the client lock");
state.last_words = format!("{}\n{text}", state.last_words);
let kept_from = state
.last_words
.char_indices()
.rev()
.nth(STDERR_KEPT)
.map(|(at, _)| at);
if let Some(at) = kept_from {
let trimmed = state.last_words[at..].trim_start().to_owned();
state.last_words = trimmed;
}
}
}
fn dispatch(&self, line: &str) {
if line.trim().is_empty() {
return;
}
let record: AgentRecord = match serde_json::from_str(line) {
Ok(record) => record,
Err(error) => {
self.log.warn(
"agent sent an unparseable line",
&fields([
("detail", error.to_string().into()),
("length", line.len().into()),
]),
);
return;
}
};
if self.dispatch_dialog(&record) {
return;
}
if self.dispatch_response(&record) {
return;
}
self.dispatch_event(&record);
}
fn dispatch_dialog(&self, record: &AgentRecord) -> bool {
let Some(dialog) = as_dialog_request(record) else {
return record.get("type").and_then(Value::as_str) == Some("extension_ui_request")
&& is_fire_and_forget(record.get("method").and_then(Value::as_str));
};
if dialog.method == DialogMethod::Editor {
if let Some(on_unsupported) = &self.handlers.on_unsupported_dialog {
on_unsupported(dialog.method.as_str().to_owned());
}
let cancelled = json!({
"type": "extension_ui_response",
"id": dialog.id,
"cancelled": true,
});
let _ = self.process.write(cancelled.to_string().as_bytes());
return true;
}
let timer = spawn_dialog_timer(
Arc::clone(&self.state),
Arc::clone(&self.handlers),
Arc::clone(&self.process),
self.dialog_timeout_ms,
dialog.clone(),
);
self.state.lock().expect("the client lock").dialogs.insert(
dialog.id.clone(),
PendingDialog {
request: dialog.clone(),
timer,
},
);
if let Some(on_dialog) = &self.handlers.on_dialog {
on_dialog(dialog.clone());
}
true
}
fn dispatch_response(&self, record: &AgentRecord) -> bool {
if record.get("type").and_then(Value::as_str) != Some("response") {
return false;
}
let id = record.get("id").and_then(Value::as_str).map(str::to_owned);
if let Some(id) = &id {
let waiting = self
.state
.lock()
.expect("the client lock")
.requests
.remove(id);
if let Some(reply) = waiting {
let _ = reply.send(record.clone());
return true;
}
}
if record.get("success") == Some(&Value::Bool(false))
&& let Some(on_rejected) = &self.handlers.on_command_rejected
{
on_rejected((
record
.get("command")
.and_then(Value::as_str)
.unwrap_or("command")
.to_owned(),
detail_of(record),
));
}
true
}
#[expect(
clippy::too_many_lines,
reason = "one table of the protocol's event kinds; splitting it would scatter the dispatch the tests read as a whole"
)]
fn dispatch_event(&self, record: &AgentRecord) {
let kind = record.get("type").and_then(Value::as_str).unwrap_or("");
match kind {
"agent_start" => {
let mut state = self.state.lock().expect("the client lock");
state.lifecycle = AgentState::Working;
state.thinking_reported = false;
state.produced_text = false;
state.turn_failure = None;
drop(state);
if let Some(on_turn_start) = &self.handlers.on_turn_start {
on_turn_start(());
}
}
"turn_end" => {
if let Some(usage) = usage_of(record)
&& let Some(on_usage) = &self.handlers.on_usage
{
on_usage(usage);
}
}
"agent_settled" => {
let (produced, failure) = {
let mut state = self.state.lock().expect("the client lock");
if state.lifecycle == AgentState::Working {
state.lifecycle = AgentState::Ready;
}
let produced = state.produced_text;
let failure = state.turn_failure.take();
state.produced_text = false;
(produced, failure)
};
if let Some(on_settled) = &self.handlers.on_turn_settled {
on_settled((produced, failure));
}
}
"message_update" => {
let reported = {
let mut state = self.state.lock().expect("the client lock");
if !state.thinking_reported && starts_thinking(record) {
state.thinking_reported = true;
true
} else {
false
}
};
if reported {
if let Some(on_thinking) = &self.handlers.on_thinking {
on_thinking(());
}
return;
}
if let Some(thought) = thinking_ended(record)
&& !thought.trim().is_empty()
&& let Some(on_thought) = &self.handlers.on_thought
{
on_thought(thought);
}
}
"message_end" => {
if message_role(record.get("message")) != Some("assistant".to_owned()) {
return;
}
let failure = message_failure(record.get("message"));
if let Some(failure) = failure {
self.state.lock().expect("the client lock").turn_failure = Some(failure);
}
let text = message_text(record.get("message"));
let text = text.trim().to_owned();
if text.is_empty() {
return;
}
self.state.lock().expect("the client lock").produced_text = true;
if let Some(on_text) = &self.handlers.on_assistant_text {
on_text(text);
}
}
"tool_execution_start" => {
if let Some(on_tool_start) = &self.handlers.on_tool_start {
on_tool_start((
record
.get("toolCallId")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
record
.get("toolName")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_owned(),
tool_target(record.get("args")),
));
}
}
"tool_execution_end" => {
if let Some(on_tool_end) = &self.handlers.on_tool_end {
on_tool_end((
record
.get("toolCallId")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
record
.get("toolName")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_owned(),
record.get("isError") == Some(&Value::Bool(true)),
format!(
"{}{}",
message_text(record.get("result")),
message_text(Some(record))
),
));
}
}
"auto_retry_start" => {
if let Some(on_retry) = &self.handlers.on_retry {
on_retry(detail_of(record));
}
}
"extension_error" => {
if let Some(on_error) = &self.handlers.on_error {
on_error(detail_of(record));
}
}
_ => {}
}
}
fn fail_pending_requests(&self, reason: &str) {
let waiting: Vec<oneshot::Sender<AgentRecord>> = {
let mut state = self.state.lock().expect("the client lock");
let ids: Vec<String> = state.requests.keys().cloned().collect();
ids.iter()
.filter_map(|id| state.requests.remove(id))
.collect()
};
for reply in waiting {
let _ = reply.send(json!({ "failed": reason }));
}
}
}
pub(crate) fn spawn_dialog_timer(
state: Arc<Mutex<ClientState>>,
handlers: Arc<AgentHandlers>,
process: Arc<dyn AgentProcess>,
timeout_ms: u64,
request: DialogRequest,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(run_dialog_timer(
state, handlers, process, timeout_ms, request,
))
}
async fn run_dialog_timer(
state: Arc<Mutex<ClientState>>,
handlers: Arc<AgentHandlers>,
process: Arc<dyn AgentProcess>,
timeout_ms: u64,
request: DialogRequest,
) {
tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)).await;
let was_pending = state
.lock()
.expect("the client lock")
.dialogs
.remove(&request.id)
.is_some();
if !was_pending {
return;
}
let cancelled = json!({
"type": "extension_ui_response",
"id": request.id,
"cancelled": true,
});
let _ = process.write(cancelled.to_string().as_bytes());
if let Some(on_timeout) = &handlers.on_dialog_timeout {
on_timeout(request);
}
}
#[cfg(test)]
mod tests;