1+ //! ACP (Agent Client Protocol) stdio bridge: exposes the Palyra gateway as an
2+ //! ACP agent so external editors and IDE clients can drive sessions, prompts,
3+ //! tool approvals, and session listings over stdin/stdout JSON-RPC. Bindings
4+ //! map ACP session ids onto gateway sessions and optional daemon-side state.
5+
16use std:: {
27 collections:: HashMap ,
38 path:: { Path , PathBuf } ,
@@ -18,16 +23,21 @@ use crate::{
1823 AgentApprovalMode , AgentConnection , AgentRunInputArgs , SessionResolveInput ,
1924} ;
2025
26+ // `_meta` extension keys accepted on session/new, session/load, and
27+ // session/prompt to override the CLI-provided session defaults per request.
2128const META_SESSION_KEY : & str = "sessionKey" ;
2229const META_SESSION_LABEL : & str = "sessionLabel" ;
2330const META_RESET_SESSION : & str = "resetSession" ;
2431const META_REQUIRE_EXISTING : & str = "requireExisting" ;
2532
33+ // Permission option ids surfaced to the ACP client; the `-always` variants map
34+ // to a session-scoped `ApprovalDecisionScope` when forwarded to the gateway.
2635const PERMISSION_ALLOW_ONCE : & str = "allow-once" ;
2736const PERMISSION_ALLOW_ALWAYS : & str = "allow-always" ;
2837const PERMISSION_REJECT_ONCE : & str = "reject-once" ;
2938const PERMISSION_REJECT_ALWAYS : & str = "reject-always" ;
3039
40+ /// Resolved link between an ACP session id and its gateway session identity.
3141#[ derive( Debug , Clone ) ]
3242struct SessionBinding {
3343 gateway_session_id_ulid : String ,
@@ -36,6 +46,8 @@ struct SessionBinding {
3646 cwd : PathBuf ,
3747}
3848
49+ /// Optional daemon control-plane channel used to persist session bindings and
50+ /// session config beyond the lifetime of this bridge process.
3951#[ derive( Clone ) ]
4052struct AcpDaemonControl {
4153 client : Arc < TokioMutex < ControlPlaneClient > > ,
@@ -45,7 +57,11 @@ struct AcpDaemonControl {
4557}
4658
4759impl AcpDaemonControl {
60+ /// Sends one `console/v1/acp/command` request and unwraps its envelope.
4861 async fn command ( & self , command : & str , params : Value ) -> acp:: Result < Value > {
62+ // The `client` block restates the full handshake (scopes plus
63+ // capabilities) on every command because the console ACP endpoint is
64+ // stateless across requests.
4965 let payload = json ! ( {
5066 "client" : {
5167 "protocol_version" : 1 ,
@@ -106,6 +122,11 @@ impl AcpDaemonControl {
106122 }
107123}
108124
125+ /// In-memory binding and active-run registry shared across ACP handlers.
126+ ///
127+ /// Bindings are indexed three ways because clients may echo any of the three
128+ /// identifiers (ACP session id, gateway session key, gateway session ulid) as
129+ /// the ACP `SessionId` on follow-up requests.
109130#[ derive( Debug , Default ) ]
110131struct BridgeState {
111132 bindings_by_acp_session_id : HashMap < String , SessionBinding > ,
@@ -146,6 +167,8 @@ impl BridgeState {
146167 }
147168}
148169
170+ /// Client-directed calls funneled through one dispatch task so agent handlers
171+ /// can stay `Clone` while `AgentSideConnection` is owned by a single task.
149172enum ClientBridgeRequest {
150173 SessionUpdate {
151174 notification : acp:: SessionNotification ,
@@ -157,6 +180,7 @@ enum ClientBridgeRequest {
157180 } ,
158181}
159182
183+ /// ACP `Agent` implementation backed by the gateway gRPC runtime client.
160184#[ derive( Clone ) ]
161185struct PalyraAcpAgent {
162186 connection : AgentConnection ,
@@ -168,6 +192,7 @@ struct PalyraAcpAgent {
168192 default_cwd : PathBuf ,
169193}
170194
195+ /// Per-request `_meta` overrides parsed from an ACP request, if any.
171196#[ derive( Debug , Default , Clone ) ]
172197struct SessionMetaOverrides {
173198 session_key : Option < String > ,
@@ -176,6 +201,8 @@ struct SessionMetaOverrides {
176201 require_existing : Option < bool > ,
177202}
178203
204+ /// Session defaults supplied via CLI flags, applied whenever an ACP request
205+ /// does not carry the corresponding `_meta` override.
179206#[ derive( Debug , Default , Clone ) ]
180207pub ( crate ) struct AcpSessionDefaults {
181208 pub ( crate ) session_key : Option < String > ,
@@ -295,6 +322,9 @@ impl PalyraAcpAgent {
295322 }
296323 }
297324
325+ // Gateway runs accept plain text, so only text-bearing blocks are kept:
326+ // text, embedded text resources, and resource links (serialized inline).
327+ // Image/audio blocks are intentionally dropped.
298328 fn prompt_text ( prompt : & [ acp:: ContentBlock ] ) -> String {
299329 let mut chunks = Vec :: new ( ) ;
300330 for block in prompt {
@@ -543,6 +573,10 @@ impl PalyraAcpAgent {
543573 let mut run_stream =
544574 client. open_run_stream ( initial_request) . await . map_err ( acp_internal_error) ?;
545575
576+ // AIDEV-NOTE: if streaming below fails mid-run (any `?` until the end
577+ // of this function), the active_runs entry is not removed, so a later
578+ // session/cancel would target the dead run id. Cleaning this up needs
579+ // a behavior change (e.g. a scope guard around the stream loop).
546580 {
547581 let mut state = self . lock_state ( ) ?;
548582 state
@@ -640,6 +674,9 @@ impl PalyraAcpAgent {
640674 }
641675 Some ( common_v1:: run_stream_event:: Body :: Status ( status) ) => {
642676 if status. kind == common_v1:: stream_status:: StatusKind :: Failed as i32 {
677+ // The stream contract has no structured cancelled
678+ // status; cancellations arrive as Failed with a
679+ // cancel-flavored message, hence the text heuristic.
643680 stop_reason = if status. message . to_ascii_lowercase ( ) . contains ( "cancel" ) {
644681 acp:: StopReason :: Cancelled
645682 } else {
@@ -801,6 +838,8 @@ impl acp::Agent for PalyraAcpAgent {
801838 & self ,
802839 arguments : acp:: ListSessionsRequest ,
803840 ) -> acp:: Result < acp:: ListSessionsResponse > {
841+ // Prefer the daemon listing (it includes persisted bindings); fall
842+ // back to the gateway list when no daemon control-plane is attached.
804843 if let Some ( response) = self . list_daemon_sessions ( arguments. cursor . clone ( ) ) . await ? {
805844 return Ok ( response) ;
806845 }
@@ -810,6 +849,14 @@ impl acp::Agent for PalyraAcpAgent {
810849 }
811850}
812851
852+ /// Runs the ACP stdio bridge until the client closes the stream.
853+ ///
854+ /// Blocks the calling thread on a dedicated Tokio runtime; the connection
855+ /// futures are `!Send`, so everything is driven on a single-thread `LocalSet`.
856+ ///
857+ /// # Errors
858+ /// Returns an error when the runtime cannot be built, the daemon ACP
859+ /// control-plane connection fails, or the stdio I/O loop terminates abnormally.
813860pub fn run_agent_acp_bridge (
814861 connection : AgentConnection ,
815862 control_plane_overrides : app:: ConnectionOverrides ,
@@ -821,6 +868,9 @@ pub fn run_agent_acp_bridge(
821868 let local_set = tokio:: task:: LocalSet :: new ( ) ;
822869 local_set
823870 . run_until ( async move {
871+ // Unbounded is safe here: every sender awaits its oneshot
872+ // response before sending again, so the queue depth is bounded
873+ // by the number of in-flight ACP requests.
824874 let ( client_request_tx, mut client_request_rx) =
825875 mpsc:: unbounded_channel :: < ClientBridgeRequest > ( ) ;
826876 let state = Arc :: new ( Mutex :: new ( BridgeState :: default ( ) ) ) ;
@@ -846,6 +896,9 @@ pub fn run_agent_acp_bridge(
846896 default_cwd,
847897 ) ;
848898
899+ // Blocking stdio wrapped as async is acceptable: this runtime
900+ // exists solely for the bridge, so a stalled read cannot
901+ // starve unrelated tasks.
849902 let outgoing = AllowStdIo :: new ( std:: io:: stdout ( ) ) ;
850903 let incoming = AllowStdIo :: new ( std:: io:: stdin ( ) ) ;
851904 let ( conn, handle_io) =
@@ -886,6 +939,10 @@ fn parse_json_bytes(raw: &[u8]) -> Option<Value> {
886939 serde_json:: from_slice :: < Value > ( raw) . ok ( )
887940}
888941
942+ /// Maps a gateway tool-approval request onto an ACP permission request.
943+ ///
944+ /// Returns `Ok(None)` when the approval carries no proposal id: without it the
945+ /// client's decision could not be correlated back to the gateway proposal.
889946fn build_tool_permission_request (
890947 session_id : & acp:: SessionId ,
891948 approval : & common_v1:: ToolApprovalRequest ,
@@ -948,6 +1005,11 @@ fn build_tool_permission_request(
9481005 Ok ( Some ( acp:: RequestPermissionRequest :: new ( session_id. clone ( ) , tool_call, options) ) )
9491006}
9501007
1008+ /// Translates an ACP permission outcome into the gateway approval fields
1009+ /// `(approved, reason, decision_scope, decision_scope_ttl_ms)`.
1010+ ///
1011+ /// Unknown option ids and non-selection outcomes fail closed as a once-scoped
1012+ /// denial; the TTL is never set so the gateway default applies.
9511013fn map_permission_outcome (
9521014 response : acp:: RequestPermissionResponse ,
9531015) -> ( bool , String , i32 , Option < i64 > ) {
@@ -999,6 +1061,8 @@ fn map_permission_outcome(
9991061 }
10001062}
10011063
1064+ /// Maps gateway session summaries to ACP session infos, preferring locally
1065+ /// remembered bindings for cwd/label and falling back to gateway fields.
10021066fn map_list_sessions_response (
10031067 response : gateway_v1:: ListSessionsResponse ,
10041068 state : & BridgeState ,
@@ -1034,6 +1098,8 @@ fn map_list_sessions_response(
10341098 . next_cursor ( non_empty ( Some ( response. next_after_session_key ) ) )
10351099}
10361100
1101+ /// Maps a daemon `session.list` JSON response to ACP session infos; unnamed
1102+ /// sessions fall back to their id and the bridge's default cwd.
10371103fn map_daemon_sessions_response ( response : Value , default_cwd : & Path ) -> acp:: ListSessionsResponse {
10381104 let sessions = response
10391105 . get ( "sessions" )
0 commit comments