use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use agent_client_protocol_schema::v1::{
ContentBlock, PromptRequest, SessionConfigOptionValue, SessionId,
SetSessionConfigOptionRequest, TextContent,
};
use aion_core::{
ActivityEvent, InterventionCapabilities, InterventionCommand, InterventionKind, Payload,
};
use aion_integrations::HarnessError;
use aion_integrations::contract::AgentSession;
use aion_integrations::jsonrpc::{
JsonRpcConnection, JsonRpcId, JsonRpcNotification, JsonRpcRequest,
};
use async_trait::async_trait;
use futures::stream::BoxStream;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::process::Child;
use tokio::sync::{mpsc, oneshot};
use crate::containment::ProcessGroup;
use crate::messages::MessageAccumulator;
use crate::outcome::{PromptOutcome, PumpEnd};
use crate::permission::PermissionPolicy;
use crate::protocol;
use crate::pump::{
OpenTurn, PendingRequests, PendingTurn, PumpContext, pump_loop, register_pending_request,
retract_pending_request,
};
use crate::translate::{self, EventIdentity};
pub(crate) struct SessionStart<R, W> {
pub(crate) connection: Arc<JsonRpcConnection<R, W>>,
pub(crate) session_id: SessionId,
pub(crate) prompt_id: JsonRpcId,
pub(crate) policy: PermissionPolicy,
pub(crate) identity: EventIdentity,
pub(crate) deferred: Vec<JsonRpcNotification>,
pub(crate) load_replay: std::ops::Range<usize>,
pub(crate) child: Option<Child>,
pub(crate) exit_grace: Duration,
pub(crate) load_session: bool,
pub(crate) config_options: Option<serde_json::Value>,
pub(crate) process_group: Option<ProcessGroup>,
}
pub struct AcpSession<R, W> {
connection: Option<Arc<JsonRpcConnection<R, W>>>,
capabilities: InterventionCapabilities,
session_id: SessionId,
events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
outcome: Option<oneshot::Receiver<PumpEnd>>,
cancelled: Arc<AtomicBool>,
pump: Option<tokio::task::JoinHandle<()>>,
child: Option<Child>,
exit_grace: Duration,
pending: PendingTurn,
busy: Arc<AtomicBool>,
ended: Arc<AtomicBool>,
load_session: bool,
initial_config_options: Option<serde_json::Value>,
requests: PendingRequests,
process_group: Option<ProcessGroup>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AcpConfigValue {
Choice(String),
Toggle(bool),
}
impl<R, W> AcpSession<R, W>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
#[must_use]
pub const fn initial_config_options(&self) -> Option<&serde_json::Value> {
self.initial_config_options.as_ref()
}
pub async fn set_config_option(
&self,
config_id: &str,
value: AcpConfigValue,
) -> Result<serde_json::Value, HarnessError> {
if self.ended.load(Ordering::SeqCst) {
return Err(HarnessError::stale_target(
"this ACP session's reader has stopped — the agent exited or its transport failed \
— so there is no live conversation left to configure; start a new session",
));
}
let connection = self.connection.as_ref().ok_or_else(|| {
HarnessError::stale_target(
"this ACP session has already been shut down; there is no live conversation left \
to configure",
)
})?;
let wire_value = match value {
AcpConfigValue::Choice(id) => SessionConfigOptionValue::value_id(id),
AcpConfigValue::Toggle(flag) => SessionConfigOptionValue::boolean(flag),
};
let request = SetSessionConfigOptionRequest::new(
self.session_id.clone(),
config_id.to_owned(),
wire_value,
);
let params = serde_json::to_value(&request).map_err(|source| {
HarnessError::protocol(format!(
"session/set_config_option params are not encodable: {source}"
))
})?;
let request_id = connection.next_request_id();
let (answer_tx, answer_rx) = tokio::sync::oneshot::channel();
register_pending_request(&self.requests, request_id.clone(), answer_tx);
let frame = JsonRpcRequest::new(
request_id.clone(),
protocol::METHOD_SESSION_SET_CONFIG_OPTION,
Some(params),
);
if let Err(error) = connection.send_request(&frame).await {
retract_pending_request(&self.requests, &request_id);
return Err(error);
}
let response = answer_rx.await.map_err(|_| {
HarnessError::stale_target(
"the agent's reader stopped before it answered this configuration change; the \
option's state is whatever the agent last advertised",
)
})?;
if let Some(error) = response.error {
return Err(HarnessError::harness(format!(
"the agent refused `session/set_config_option`: {} (code {})",
error.message, error.code
)));
}
response.result.ok_or_else(|| {
HarnessError::protocol(
"the agent answered `session/set_config_option` with neither result nor error"
.to_owned(),
)
})
}
pub(crate) fn start(start: SessionStart<R, W>) -> Self {
let SessionStart {
connection,
session_id,
prompt_id,
policy,
identity,
deferred,
load_replay,
child,
exit_grace,
load_session,
config_options,
process_group,
} = start;
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (outcome_tx, outcome_rx) = oneshot::channel();
let cancelled = Arc::new(AtomicBool::new(false));
let pending: PendingTurn = Arc::new(tokio::sync::Mutex::new(None));
let requests: PendingRequests = Arc::new(std::sync::Mutex::new(Vec::new()));
let busy = Arc::new(AtomicBool::new(true));
let ended = Arc::new(AtomicBool::new(false));
let pump = tokio::spawn(pump_loop(
PumpContext {
connection: Arc::clone(&connection),
session_id: session_id.clone(),
policy,
cancelled: Arc::clone(&cancelled),
identity,
pending: Arc::clone(&pending),
requests: Arc::clone(&requests),
busy: Arc::clone(&busy),
ended: Arc::clone(&ended),
},
deferred,
load_replay,
OpenTurn {
prompt_id,
event_tx,
outcome_tx,
accumulator: MessageAccumulator::new(),
},
));
Self {
connection: Some(connection),
capabilities: translate::negotiated_capabilities(),
session_id,
events: Some(event_rx),
outcome: Some(outcome_rx),
cancelled,
pump: Some(pump),
child,
exit_grace,
pending,
busy,
ended,
load_session,
initial_config_options: config_options,
requests,
process_group,
}
}
#[must_use]
pub const fn process_group(&self) -> Option<ProcessGroup> {
self.process_group
}
#[must_use]
pub const fn supports_load_session(&self) -> bool {
self.load_session
}
#[must_use]
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
#[must_use]
pub fn is_live(&self) -> bool {
!self.ended.load(Ordering::SeqCst)
}
pub fn take_first_turn(&mut self) -> Option<TurnHandle> {
let outcome = self.outcome.take()?;
Some(TurnHandle {
session_id: self.session_id.clone(),
events: self.events.take(),
outcome,
})
}
pub async fn prompt(&self, text: String) -> Result<TurnHandle, HarnessError> {
if self.ended.load(Ordering::SeqCst) {
return Err(HarnessError::stale_target(
"this ACP session's reader has stopped — the agent exited or its transport failed \
— so there is no live conversation left to prompt; start a new session",
));
}
let connection = self.connection.as_ref().ok_or_else(|| {
HarnessError::stale_target(
"this ACP session has already been shut down; there is no live conversation left \
to prompt",
)
})?;
let request = PromptRequest::new(
self.session_id.clone(),
vec![ContentBlock::Text(TextContent::new(text))],
);
let params = serde_json::to_value(&request).map_err(|source| {
HarnessError::protocol(format!("session/prompt params are not encodable: {source}"))
})?;
if self
.busy
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return Err(HarnessError::occupied(
"this ACP session already has a prompt turn open — an agent holds one conversation \
and ACP's streamed updates carry no turn id, so a second concurrent prompt could \
not be attributed; cancel the open turn or wait for its result",
));
}
let prompt_id = connection.next_request_id();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (outcome_tx, outcome_rx) = oneshot::channel();
*self.pending.lock().await = Some(OpenTurn {
prompt_id: prompt_id.clone(),
event_tx,
outcome_tx,
accumulator: MessageAccumulator::new(),
});
let frame = JsonRpcRequest::new(prompt_id, protocol::METHOD_SESSION_PROMPT, Some(params));
if let Err(error) = connection.send_request(&frame).await {
if self.pending.lock().await.take().is_some() {
self.busy.store(false, Ordering::SeqCst);
}
return Err(error);
}
Ok(TurnHandle {
session_id: self.session_id.clone(),
events: Some(event_rx),
outcome: outcome_rx,
})
}
pub async fn close(mut self) {
self.shut_down_agent().await;
}
async fn terminal_result(&mut self) -> Result<Payload, HarnessError> {
let receiver = self
.outcome
.take()
.ok_or_else(|| HarnessError::protocol("the prompt result was already awaited"))?;
let end = receiver.await.map_err(|_recv| {
HarnessError::transport(
"the ACP reader pump ended without reporting a terminal outcome: the pump task did \
not complete (it panicked, or the session was shut down), so no session/prompt \
answer was ever interpreted",
)
})?;
end.into_payload(&self.session_id)
}
async fn shut_down_agent(&mut self) {
if let Some(pump) = self.pump.take() {
pump.abort();
match pump.await {
Ok(()) => {}
Err(error) if error.is_cancelled() => {}
Err(error) => {
tracing::error!(%error, "acp adapter: the reader pump task panicked");
}
}
}
drop(self.connection.take());
let Some(mut child) = self.child.take() else {
return;
};
if let Some(group) = self.process_group {
group.terminate();
}
if self.exit_grace.is_zero() {
if let Some(group) = self.process_group {
group.kill();
}
kill_and_reap(&mut child, "no exit grace is configured for this harness").await;
return;
}
match tokio::time::timeout(self.exit_grace, child.wait()).await {
Ok(Ok(status)) => {
tracing::debug!(
?status,
"acp adapter: the agent exited on its own after its stdin closed"
);
if let Some(group) = self.process_group
&& !group.is_gone()
{
tracing::warn!(
process_group = group.as_raw(),
"acp adapter: the agent exited but its process group is still observable; \
a descendant outlived it and cannot be signalled now that the leader has \
been reaped"
);
}
}
Ok(Err(source)) => tracing::warn!(
%source,
"acp adapter: could not wait for the agent to exit; it may still be running"
),
Err(_elapsed) => {
if let Some(group) = self.process_group {
group.kill();
}
kill_and_reap(
&mut child,
"the agent did not exit within the configured grace after its stdin closed",
)
.await;
}
}
}
}
#[async_trait]
impl<R, W> AgentSession for AcpSession<R, W>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
fn capabilities(&self) -> &InterventionCapabilities {
&self.capabilities
}
fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
match self.events.take() {
Some(receiver) => Box::pin(futures::stream::unfold(
receiver,
|mut receiver| async move { receiver.recv().await.map(|event| (event, receiver)) },
)),
None => Box::pin(futures::stream::empty()),
}
}
async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
if !self.capabilities.supports(&cmd.kind) {
return Err(HarnessError::capability_not_supported(
translate::primitive_label(cmd.kind.primitive()),
));
}
if let InterventionKind::Cancel { reason } = &cmd.kind {
self.cancelled.store(true, Ordering::SeqCst);
tracing::info!(
session = %self.session_id,
reason,
"acp adapter: cancelling the prompt turn"
);
}
let notification = translate::intervention_to_notification(&self.session_id, &cmd.kind)?;
self.send(notification).await
}
async fn wait_result(mut self) -> Result<Payload, HarnessError> {
let result = self.terminal_result().await;
self.shut_down_agent().await;
result
}
}
impl<R, W> AcpSession<R, W>
where
R: AsyncRead + Unpin + Send,
W: AsyncWrite + Unpin + Send,
{
async fn send(&self, notification: translate::AcpNotification) -> Result<(), HarnessError> {
let connection = self.connection.as_ref().ok_or_else(|| {
HarnessError::stale_target(
"the ACP session has already been shut down; there is no turn left to intervene in",
)
})?;
let frame = JsonRpcNotification::new(notification.method, Some(notification.params));
connection.send_notification(&frame).await
}
}
pub struct TurnHandle {
session_id: SessionId,
events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
outcome: oneshot::Receiver<PumpEnd>,
}
impl TurnHandle {
#[must_use]
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
pub fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
match self.events.take() {
Some(receiver) => Box::pin(futures::stream::unfold(
receiver,
|mut receiver| async move { receiver.recv().await.map(|event| (event, receiver)) },
)),
None => Box::pin(futures::stream::empty()),
}
}
pub async fn wait(self) -> Result<PromptOutcome, HarnessError> {
let end = self.outcome.await.map_err(|_recv| {
HarnessError::transport(
"the ACP reader pump ended without reporting a terminal outcome for this turn: the \
pump task did not complete (it panicked, or the session was shut down), so no \
session/prompt answer was ever interpreted",
)
})?;
end.into_outcome()
}
}
async fn kill_and_reap(child: &mut Child, why: &str) {
if let Err(source) = child.kill().await {
tracing::warn!(
%source,
why,
"acp adapter: could not kill and reap the agent process"
);
return;
}
tracing::debug!(why, "acp adapter: the agent process was killed and reaped");
}
impl<R, W> Drop for AcpSession<R, W> {
fn drop(&mut self) {
if let Some(pump) = self.pump.take() {
pump.abort();
}
if let Some(mut child) = self.child.take() {
if let Some(group) = self.process_group {
group.kill();
}
if let Err(source) = child.start_kill() {
tracing::warn!(
%source,
"acp adapter: could not signal the agent process while dropping the session"
);
}
}
}
}
pub(crate) async fn refuse_peer_request<R, W>(
connection: &JsonRpcConnection<R, W>,
request: JsonRpcRequest,
) -> Result<(), HarnessError>
where
R: AsyncRead + Unpin + Send,
W: AsyncWrite + Unpin + Send,
{
tracing::warn!(
method = %request.method,
"acp adapter: refusing a peer request received during the handshake"
);
let refusal = protocol::unadvertised_method_response(request.id, &request.method);
connection.send_response(&refusal).await
}