use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use agent_client_protocol_schema::v1::{
PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, SessionId,
SessionNotification, SessionUpdate,
};
use aion_core::{ActivityEvent, ActivityEventKind, StopKind};
use aion_integrations::HarnessError;
use aion_integrations::jsonrpc::{
IncomingMessage, JsonRpcConnection, JsonRpcError, JsonRpcId, JsonRpcNotification,
JsonRpcRequest, JsonRpcResponse,
};
use serde_json::{Value, json};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinSet;
use crate::messages::MessageAccumulator;
use crate::outcome::{PromptOutcome, PumpEnd};
use crate::permission::PermissionPolicy;
use crate::protocol;
use crate::translate::{self, EventIdentity, EventKind};
mod output;
use output::{emit, emit_for, queue_response};
const UNREADABLE_FRAME_SOURCE: &str = "acp/unreadable_frame";
pub(crate) struct OpenTurn {
pub(crate) prompt_id: JsonRpcId,
pub(crate) event_tx: mpsc::UnboundedSender<ActivityEvent>,
pub(crate) outcome_tx: oneshot::Sender<PumpEnd>,
pub(crate) accumulator: MessageAccumulator,
}
pub(crate) type PendingTurn = Arc<tokio::sync::Mutex<Option<OpenTurn>>>;
pub(crate) type PendingRequests =
Arc<std::sync::Mutex<Vec<(JsonRpcId, oneshot::Sender<JsonRpcResponse>)>>>;
pub(crate) fn register_pending_request(
requests: &PendingRequests,
id: JsonRpcId,
sender: oneshot::Sender<JsonRpcResponse>,
) {
let mut held = match requests.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
held.push((id, sender));
}
pub(crate) fn retract_pending_request(requests: &PendingRequests, id: &JsonRpcId) {
let mut held = match requests.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
held.retain(|(pending, _)| pending != id);
}
fn take_pending_request(
requests: &PendingRequests,
id: &JsonRpcId,
) -> Option<oneshot::Sender<JsonRpcResponse>> {
let mut held = match requests.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let position = held.iter().position(|(pending, _)| pending == id)?;
Some(held.remove(position).1)
}
fn fail_pending_requests(requests: &PendingRequests) {
let mut held = match requests.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
held.clear();
}
pub(crate) struct PumpContext<R, W> {
pub(crate) connection: Arc<JsonRpcConnection<R, W>>,
pub(crate) session_id: SessionId,
pub(crate) policy: PermissionPolicy,
pub(crate) cancelled: Arc<AtomicBool>,
pub(crate) identity: EventIdentity,
pub(crate) pending: PendingTurn,
pub(crate) requests: PendingRequests,
pub(crate) busy: Arc<AtomicBool>,
pub(crate) ended: Arc<AtomicBool>,
}
struct PumpState {
worker_seq: u64,
open: Option<OpenTurn>,
writers: JoinSet<()>,
}
pub(crate) async fn pump_loop<R, W>(
context: PumpContext<R, W>,
deferred: Vec<JsonRpcNotification>,
load_replay: std::ops::Range<usize>,
first_turn: OpenTurn,
) where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let mut state = PumpState {
worker_seq: 0,
open: Some(first_turn),
writers: JoinSet::new(),
};
for (index, note) in deferred.into_iter().enumerate() {
if load_replay.contains(&index) {
replay_notification(&context, &mut state, note);
} else {
handle_notification(&context, &mut state, note);
}
}
loop {
let message = context.connection.recv().await;
adopt_pending_turn(&context, &mut state).await;
match message {
Ok(Some(IncomingMessage::Notification(note))) => {
handle_notification(&context, &mut state, note);
}
Ok(Some(IncomingMessage::Response(response))) => {
handle_response(&context, &mut state, response);
}
Ok(Some(IncomingMessage::Request(request))) => {
handle_peer_request(&context, &mut state, request);
}
Ok(None) => {
end_pump(&context, &mut state, UnansweredEnd::StreamEnded).await;
return;
}
Err(HarnessError::Protocol { detail }) => {
record_unreadable_frame(&context, &mut state, &detail);
}
Err(error) => {
let detail = error.to_string();
tracing::warn!(%detail, "acp adapter: reader pump ended on a transport error");
end_pump(&context, &mut state, UnansweredEnd::TransportFailed(detail)).await;
return;
}
}
}
}
async fn adopt_pending_turn<R, W>(context: &PumpContext<R, W>, state: &mut PumpState) {
if state.open.is_some() {
return;
}
let Some(turn) = context.pending.lock().await.take() else {
return;
};
tracing::debug!(
session = %context.session_id,
id = ?turn.prompt_id,
"acp adapter: adopted a new prompt turn on the live session"
);
state.open = Some(turn);
}
async fn end_pump<R, W>(context: &PumpContext<R, W>, state: &mut PumpState, end: UnansweredEnd) {
context.ended.store(true, Ordering::SeqCst);
fail_pending_requests(&context.requests);
if let Some(stranded) = context.pending.lock().await.take() {
let _ = stranded.outcome_tx.send(end.clone().into_pump_end());
}
report_end(context, state, end.into_pump_end());
}
#[derive(Clone)]
enum UnansweredEnd {
StreamEnded,
TransportFailed(String),
}
impl UnansweredEnd {
fn into_pump_end(self) -> PumpEnd {
match self {
Self::StreamEnded => PumpEnd::StreamEnded,
Self::TransportFailed(detail) => PumpEnd::TransportFailed { detail },
}
}
}
fn record_unreadable_frame<R, W>(context: &PumpContext<R, W>, state: &mut PumpState, detail: &str) {
tracing::warn!(
%detail,
"acp adapter: a line on the agent's stdout is not a JSON-RPC frame; recorded and skipped"
);
let kind = translate::raw_kind(
UNREADABLE_FRAME_SOURCE,
&json!({
"error": detail,
"recovered": true,
"note": "the line was skipped and the turn continued; the transport does not surface \
the line's own bytes",
}),
);
emit(context, state, vec![kind]);
}
fn report_end<R, W>(context: &PumpContext<R, W>, state: &mut PumpState, end: PumpEnd) {
if let Some(open) = state.open.take() {
let _ = open.outcome_tx.send(end);
}
context.busy.store(false, Ordering::SeqCst);
}
pub(crate) const LOAD_REPLAY_SOURCE: &str = "session/load-replay";
fn replay_notification<R, W>(
context: &PumpContext<R, W>,
state: &mut PumpState,
note: JsonRpcNotification,
) {
if note.method != protocol::METHOD_SESSION_UPDATE {
handle_notification(context, state, note);
return;
}
let params = note.params.clone().unwrap_or(Value::Null);
if let Ok(notification) = serde_json::from_value::<SessionNotification>(params.clone())
&& is_standing_update(¬ification.update)
{
handle_notification(context, state, note);
return;
}
emit(
context,
state,
vec![translate::raw_kind(LOAD_REPLAY_SOURCE, ¶ms)],
);
}
const fn is_standing_update(update: &SessionUpdate) -> bool {
matches!(
update,
SessionUpdate::AvailableCommandsUpdate(_)
| SessionUpdate::CurrentModeUpdate(_)
| SessionUpdate::ConfigOptionUpdate(_)
| SessionUpdate::SessionInfoUpdate(_)
)
}
fn handle_notification<R, W>(
context: &PumpContext<R, W>,
state: &mut PumpState,
note: JsonRpcNotification,
) {
let params = note.params.unwrap_or(Value::Null);
if note.method == protocol::METHOD_SESSION_UPDATE {
emit_session_update(context, state, ¶ms);
return;
}
if note.method == protocol::METHOD_CANCEL_REQUEST {
tracing::debug!(
method = %note.method,
"acp adapter: agent cancelled a request; this client holds none pending"
);
} else {
tracing::warn!(method = %note.method, "acp adapter: unrecognised notification method");
}
emit(
context,
state,
vec![translate::raw_kind(note.method.as_str(), ¶ms)],
);
}
fn emit_session_update<R, W>(context: &PumpContext<R, W>, state: &mut PumpState, params: &Value) {
let notification: SessionNotification = match serde_json::from_value(params.clone()) {
Ok(notification) => notification,
Err(source) => {
tracing::warn!(%source, "acp adapter: undecodable session/update params");
let kind = translate::raw_kind(
format!("{}/undecodable", translate::UPDATE_SOURCE_PREFIX),
&json!({ "error": source.to_string(), "params": params }),
);
emit(context, state, vec![kind]);
return;
}
};
if notification.session_id != context.session_id {
tracing::warn!(
received = %notification.session_id,
expected = %context.session_id,
"acp adapter: session/update for an unexpected session id"
);
let kind = translate::raw_kind(
format!("{}/foreign_session", translate::UPDATE_SOURCE_PREFIX),
¬ification,
);
emit(context, state, vec![kind]);
return;
}
let Some(open) = state.open.as_mut() else {
tracing::warn!(
session = %context.session_id,
"acp adapter: a session/update arrived with no turn open; it has no transcript to \
reach"
);
return;
};
let kinds = translate::update_to_kinds(¬ification.update, &mut open.accumulator);
emit(context, state, kinds);
}
fn handle_response<R, W>(
context: &PumpContext<R, W>,
state: &mut PumpState,
response: JsonRpcResponse,
) {
if let Some(sender) = take_pending_request(&context.requests, &response.id) {
if sender.send(response).is_err() {
tracing::warn!(
"acp adapter: a configuration request's answer arrived after its caller stopped \
waiting; the option's state is whatever the agent next advertises"
);
}
return;
}
match state.open.as_ref() {
Some(open) if open.prompt_id == response.id => {}
Some(open) => {
tracing::warn!(
received = ?response.id,
expected = ?open.prompt_id,
"acp adapter: response for an id this client never requested; ignoring the frame"
);
return;
}
None => {
tracing::warn!(
id = ?response.id,
"acp adapter: a response arrived with no turn open (a second answer for a turn \
already reported); ignoring the frame"
);
return;
}
}
let Some(mut open) = state.open.take() else {
return;
};
let flushed = open.accumulator.finish();
emit_for(context, &mut state.worker_seq, &open, flushed);
emit_for(
context,
&mut state.worker_seq,
&open,
vec![translate::prompt_response_kind(&response)],
);
emit_for(
context,
&mut state.worker_seq,
&open,
vec![stop_event_kind(&response)],
);
let outcome = PromptOutcome {
response,
text: open.accumulator.completed_text().to_owned(),
final_message: open.accumulator.final_message().to_owned(),
};
let OpenTurn {
outcome_tx,
event_tx,
..
} = open;
let _ = outcome_tx.send(PumpEnd::Answered(Box::new(outcome)));
context.busy.store(false, Ordering::SeqCst);
drop(event_tx);
}
fn stop_event_kind(response: &JsonRpcResponse) -> EventKind {
let reason = if let Some(error) = response.error.as_ref() {
StopKind::Error {
message: format!(
"session/prompt failed (code {}): {}",
error.code, error.message
),
}
} else if let Some(result) = response.result.as_ref() {
match serde_json::from_value::<PromptResponse>(result.clone()) {
Ok(prompt) => translate::stop_kind(prompt.stop_reason),
Err(source) => {
tracing::warn!(%source, "acp adapter: undecodable session/prompt result");
StopKind::Error {
message: format!(
"the session/prompt result is not an ACP PromptResponse ({source}): {result}"
),
}
}
}
} else {
StopKind::Error {
message: "the session/prompt response carried neither result nor error".to_owned(),
}
};
(ActivityEventKind::Stop { reason }, false)
}
fn handle_peer_request<R, W>(
context: &PumpContext<R, W>,
state: &mut PumpState,
request: JsonRpcRequest,
) where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
if request.method == protocol::METHOD_SESSION_REQUEST_PERMISSION {
answer_permission_request(context, state, request);
return;
}
tracing::warn!(
method = %request.method,
"acp adapter: refusing a peer request for an unadvertised capability"
);
emit(
context,
state,
vec![translate::refused_request_kind(
&request.method,
request.params.as_ref(),
)],
);
let refusal = protocol::unadvertised_method_response(request.id, &request.method);
queue_response(context, state, refusal);
}
fn answer_permission_request<R, W>(
context: &PumpContext<R, W>,
state: &mut PumpState,
request: JsonRpcRequest,
) where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let params = request.params.clone().unwrap_or(Value::Null);
let decoded: RequestPermissionRequest = match serde_json::from_value(params.clone()) {
Ok(decoded) => decoded,
Err(source) => {
tracing::warn!(%source, "acp adapter: undecodable session/request_permission params");
emit(
context,
state,
vec![translate::raw_kind(
format!(
"{}/undecodable",
protocol::METHOD_SESSION_REQUEST_PERMISSION
),
&json!({ "error": source.to_string(), "params": params }),
)],
);
let refusal = JsonRpcResponse::failure(
request.id,
JsonRpcError::new(
aion_integrations::jsonrpc::error_codes::INVALID_PARAMS,
format!("session/request_permission params are not decodable: {source}"),
),
);
queue_response(context, state, refusal);
return;
}
};
emit(
context,
state,
vec![translate::permission_request_kind(&decoded)],
);
let outcome = if context.cancelled.load(Ordering::SeqCst) {
RequestPermissionOutcome::Cancelled
} else {
context.policy.decide(&decoded.options)
};
emit(
context,
state,
vec![translate::permission_decision_kind(
context.policy,
&decoded.tool_call.tool_call_id.to_string(),
&decoded.options,
&outcome,
)],
);
let answer = match serde_json::to_value(RequestPermissionResponseShim::of(&outcome)) {
Ok(result) => JsonRpcResponse::success(request.id, result),
Err(source) => {
tracing::error!(%source, "acp adapter: could not encode a permission outcome");
JsonRpcResponse::failure(
request.id,
JsonRpcError::new(
aion_integrations::jsonrpc::error_codes::INTERNAL_ERROR,
format!("could not encode the permission outcome: {source}"),
),
)
}
};
queue_response(context, state, answer);
}
#[derive(serde::Serialize)]
struct RequestPermissionResponseShim<'a> {
outcome: &'a RequestPermissionOutcome,
}
impl<'a> RequestPermissionResponseShim<'a> {
const fn of(outcome: &'a RequestPermissionOutcome) -> Self {
Self { outcome }
}
}