Skip to main content

agentos_client/
session.rs

1//! Agent sessions (ACP) methods + supporting types.
2//!
3//! Mirrors the durable session surface in `packages/core/src/session-api.ts`.
4//! Agent types are resolved dynamically from projected package manifests; the
5//! client has no hardcoded agent registry.
6//!
7//! The sidecar owns ACP JSON-RPC, adapter restoration, and SQLite history. This
8//! client exposes typed durable operations and exact upstream ACP data types,
9//! not a second raw JSON-RPC session API.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::pin::Pin;
13
14use anyhow::Result;
15use futures::Stream;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19pub use agent_client_protocol_schema::v1::McpServer as McpServerConfig;
20
21pub use agent_client_protocol_schema::v1::{
22    AvailableCommandsUpdate, ConfigOptionUpdate, ContentBlock, ContentChunk, CurrentModeUpdate,
23    Meta, PermissionOption, Plan, RequestPermissionOutcome, RequestPermissionRequest,
24    RequestPermissionResponse, SessionConfigOption, SessionInfoUpdate, SessionUpdate, StopReason,
25    ToolCall, ToolCallUpdate, UsageUpdate,
26};
27
28use agentos_protocol::generated::v1::{
29    AcpCancelPromptRequest, AcpDeleteSessionRequest, AcpDurableEvent, AcpDurableHistoryEntry,
30    AcpDurableSessionInfo, AcpGetDurableSessionRequest, AcpGetSessionAgentInfoRequest,
31    AcpGetSessionCapabilitiesRequest, AcpGetSessionConfigRequest, AcpListAgentsRequest,
32    AcpListDurableSessionsRequest, AcpOpenSessionRequest, AcpPromptRequest, AcpReadHistoryRequest,
33    AcpRequest, AcpRespondPermissionRequest, AcpResponse, AcpSetSessionConfigOptionRequest,
34    AcpUnloadSessionRequest,
35};
36use agentos_protocol::ACP_EXTENSION_NAMESPACE;
37use agentos_sidecar_client::wire;
38
39use crate::agent_os::AgentOs;
40use crate::config::Bindings;
41use crate::error::ClientError;
42use crate::stream::Subscription;
43pub type DurableSessionEventStream = Pin<
44    Box<
45        dyn Stream<Item = std::result::Result<SessionStreamEntry, SessionSubscriptionError>> + Send,
46    >,
47>;
48pub type DurableSessionEventSubscription = (DurableSessionEventStream, Subscription);
49pub type AgentExitStream = Pin<Box<dyn Stream<Item = AgentExitEvent> + Send>>;
50pub type AgentExitSubscription = (AgentExitStream, Subscription);
51
52/// An unexpected ACP adapter process exit — a crash from the host's
53/// perspective (any spontaneous exit without `unload_session`, including exit
54/// code 0). Mirrors the wire
55/// `AcpAgentExitedEvent` and the TS `AgentExitEvent`.
56///
57/// `restart` is always `"not_attempted"`: AgentOS evicts the live route and
58/// never respawns the adapter or replays the interrupted request implicitly.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum AgentRestartOutcome {
62    NotAttempted,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct AgentExitEvent {
67    #[serde(rename = "sessionId")]
68    pub session_id: String,
69    #[serde(rename = "agentType")]
70    pub agent_type: String,
71    #[serde(rename = "processId")]
72    pub process_id: String,
73    /// Host pid when reported by the transport; `None` when unavailable.
74    pub pid: Option<u32>,
75    /// Adapter exit code; `None` when the exit was observed indirectly.
76    #[serde(rename = "exitCode")]
77    pub exit_code: Option<i32>,
78    pub restart: AgentRestartOutcome,
79    #[serde(rename = "restartCount")]
80    pub restart_count: u32,
81    #[serde(rename = "maxRestarts")]
82    pub max_restarts: u32,
83}
84
85// ---------------------------------------------------------------------------
86// Supporting types
87// ---------------------------------------------------------------------------
88
89/// A registry agent entry from `list_agents`. Mirrors the TS `AgentRegistryEntry`.
90/// The client is npm-agnostic and parses no manifests: `list_agents` is a sidecar
91/// ACP RPC that enumerates the projected `/opt/agentos` packages. The entry is just
92/// the agent `id`; `installed` is always `true` (the package is materialized into
93/// the VM at boot).
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct AgentRegistryEntry {
96    pub id: String,
97    pub installed: bool,
98}
99
100/// Immutable options used to open a durable AgentOS session. Omitted fields are
101/// forwarded as omissions so the sidecar remains the single owner of defaults.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct OpenSessionInput {
105    /// Caller-owned public session ID. Omission selects the documented `main`
106    /// ID; AgentOS never allocates or returns a different public ID.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub session_id: Option<String>,
109    pub agent: String,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub cwd: Option<String>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub additional_directories: Option<Vec<String>>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub env: Option<BTreeMap<String, String>>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub mcp_servers: Option<Vec<McpServerConfig>>,
118    /// Immutable AgentOS strategy for native ACP permission requests. Omission
119    /// selects `AllowAll`; this does not configure VM permissions or tool access.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub permission_policy: Option<PermissionPolicy>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub skip_os_instructions: Option<bool>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub additional_instructions: Option<String>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum PermissionPolicy {
131    RejectAll,
132    Ask,
133    AllowAll,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137#[serde(tag = "status", rename_all = "snake_case")]
138pub enum SessionState {
139    Idle,
140    Running {
141        #[serde(rename = "startedAt")]
142        started_at: String,
143    },
144    Waiting {
145        #[serde(rename = "waitingSince")]
146        waiting_since: String,
147        requests: Vec<PendingPermissionRequest>,
148    },
149    Failed {
150        error: Value,
151    },
152}
153
154/// SQLite-backed session metadata. Reading it never starts an ACP adapter.
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct SessionInfo {
158    pub session_id: String,
159    pub agent: String,
160    pub cwd: String,
161    pub additional_directories: Vec<String>,
162    pub state: SessionState,
163    pub latest_sequence: u64,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub title: Option<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub metadata: Option<BTreeMap<String, Value>>,
168    pub created_at: String,
169    pub updated_at: String,
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq)]
173pub struct ListSessionsInput {
174    pub cursor: Option<String>,
175    pub limit: Option<u32>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct SessionPage {
181    pub sessions: Vec<SessionInfo>,
182    pub next_cursor: Option<String>,
183}
184
185#[derive(Debug, Clone, PartialEq)]
186pub struct PromptInput {
187    pub session_id: Option<String>,
188    pub idempotency_key: Option<String>,
189    pub content: Vec<ContentBlock>,
190}
191
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub struct AgentMessage {
194    pub id: String,
195    pub role: String,
196    pub content: Vec<ContentBlock>,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct PromptResult {
202    pub session_id: String,
203    pub message: Option<AgentMessage>,
204    pub stop_reason: StopReason,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum CancelPromptStatus {
210    Cancelled,
211    NoActivePrompt,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum PermissionResponseStatus {
216    Accepted,
217    NotPending(PermissionTerminalReason),
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum PermissionTerminalReason {
223    AlreadyResolved,
224    PromptCancelled,
225    AdapterExited,
226    SessionDeleted,
227    VmShutdown,
228    RequestNotFound,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum PermissionEventStatus {
234    Accepted,
235    NotPending,
236}
237
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239#[serde(tag = "type", rename_all = "snake_case")]
240pub enum DurableSessionEvent {
241    UserMessageChunk(ContentChunk),
242    AgentMessageChunk(ContentChunk),
243    AgentThoughtChunk(ContentChunk),
244    ToolCall(ToolCall),
245    ToolCallUpdate(ToolCallUpdate),
246    Plan(Plan),
247    AvailableCommandsUpdate(AvailableCommandsUpdate),
248    CurrentModeUpdate(CurrentModeUpdate),
249    ConfigOptionUpdate(ConfigOptionUpdate),
250    SessionInfoUpdate(SessionInfoUpdate),
251    UsageUpdate(UsageUpdate),
252    PermissionRequest {
253        #[serde(rename = "requestId")]
254        request_id: String,
255        options: Vec<PermissionOption>,
256        #[serde(rename = "toolCall")]
257        tool_call: ToolCallUpdate,
258        #[serde(default, skip_serializing_if = "Option::is_none", rename = "_meta")]
259        meta: Option<Meta>,
260    },
261    PermissionResponse {
262        #[serde(rename = "requestId")]
263        request_id: String,
264        outcome: RequestPermissionOutcome,
265        #[serde(default, skip_serializing_if = "Option::is_none", rename = "_meta")]
266        meta: Option<Meta>,
267        status: PermissionEventStatus,
268        #[serde(default, skip_serializing_if = "Option::is_none")]
269        reason: Option<PermissionTerminalReason>,
270    },
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274#[serde(tag = "type", rename_all = "snake_case")]
275pub enum EphemeralSessionEvent {
276    AgentMessageChunk(ContentChunk),
277    AgentThoughtChunk(ContentChunk),
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct PendingPermissionRequest {
283    pub request_id: String,
284    pub options: Vec<PermissionOption>,
285    pub tool_call: ToolCallUpdate,
286    #[serde(default, skip_serializing_if = "Option::is_none", rename = "_meta")]
287    pub meta: Option<Meta>,
288}
289
290/// A bounded live session subscription fell behind. Durable updates remain in
291/// SQLite and can be recovered with `read_history`; ephemeral deltas cannot be
292/// recovered.
293#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
294pub enum SessionSubscriptionError {
295    #[error("session subscription lagged by {skipped} entries; resume durable history with read_history")]
296    Lagged { skipped: u64 },
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct DurableSessionEventEntry {
302    pub durability: DurableEventKind,
303    pub session_id: String,
304    pub sequence: u64,
305    pub timestamp: String,
306    #[serde(flatten)]
307    pub event: DurableSessionEvent,
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum DurableEventKind {
313    Durable,
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "snake_case")]
318pub enum EphemeralEventKind {
319    Ephemeral,
320}
321
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase")]
324pub struct EphemeralSessionEventEntry {
325    pub durability: EphemeralEventKind,
326    pub session_id: String,
327    pub after_sequence: u64,
328    #[serde(flatten)]
329    pub event: EphemeralSessionEvent,
330}
331
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(untagged)]
334pub enum SessionStreamEntry {
335    Durable(DurableSessionEventEntry),
336    Ephemeral(EphemeralSessionEventEntry),
337}
338
339#[derive(Debug, Clone, Default, PartialEq, Eq)]
340pub struct ReadHistoryInput {
341    pub session_id: Option<String>,
342    pub before: Option<u64>,
343    pub after: Option<u64>,
344    pub limit: Option<u32>,
345}
346
347#[derive(Debug, Clone, PartialEq)]
348pub struct HistoryPage {
349    pub events: Vec<DurableSessionEventEntry>,
350    pub has_more_before: bool,
351    pub has_more_after: bool,
352}
353
354#[derive(Debug, Clone, PartialEq)]
355pub struct SessionConfig {
356    pub revision: u64,
357    pub options: Vec<SessionConfigOption>,
358}
359
360/// Native ACP configuration values are deliberately limited to the protocol's
361/// string and boolean variants.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(untagged)]
364pub enum SessionConfigValue {
365    String(String),
366    Boolean(bool),
367}
368
369impl From<String> for SessionConfigValue {
370    fn from(value: String) -> Self {
371        Self::String(value)
372    }
373}
374
375impl From<&str> for SessionConfigValue {
376    fn from(value: &str) -> Self {
377        Self::String(value.to_owned())
378    }
379}
380
381impl From<bool> for SessionConfigValue {
382    fn from(value: bool) -> Self {
383        Self::Boolean(value)
384    }
385}
386
387#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase")]
389pub struct SessionCapabilities {
390    pub protocol_version: u64,
391    pub load_session: bool,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub prompt: Option<Value>,
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub mcp: Option<Value>,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub session: Option<Value>,
398    #[serde(default, skip_serializing_if = "BTreeMap::is_empty", flatten)]
399    pub extensions: BTreeMap<String, Value>,
400}
401
402fn encode_json<T: Serialize>(value: &T) -> Result<String> {
403    serde_json::to_string(value).map_err(Into::into)
404}
405
406fn encode_optional_json<T: Serialize>(value: Option<T>) -> Result<Option<String>> {
407    value.as_ref().map(encode_json).transpose()
408}
409
410fn decode_json<T: for<'de> Deserialize<'de>>(value: &str) -> Result<T> {
411    serde_json::from_str(value).map_err(Into::into)
412}
413
414fn decode_optional_json<T: for<'de> Deserialize<'de>>(value: Option<String>) -> Result<Option<T>> {
415    value.as_deref().map(decode_json).transpose()
416}
417
418fn permission_policy_wire(policy: PermissionPolicy) -> String {
419    match policy {
420        PermissionPolicy::RejectAll => String::from("reject_all"),
421        PermissionPolicy::Ask => String::from("ask"),
422        PermissionPolicy::AllowAll => String::from("allow_all"),
423    }
424}
425
426fn session_stream_id(entry: &SessionStreamEntry) -> &str {
427    match entry {
428        SessionStreamEntry::Durable(entry) => &entry.session_id,
429        SessionStreamEntry::Ephemeral(entry) => &entry.session_id,
430    }
431}
432
433fn decode_session_info(value: AcpDurableSessionInfo) -> Result<SessionInfo> {
434    Ok(SessionInfo {
435        session_id: value.session_id,
436        agent: value.agent,
437        cwd: value.cwd,
438        additional_directories: decode_json(&value.additional_directories)?,
439        state: decode_json(&value.state)?,
440        latest_sequence: value.latest_sequence,
441        title: value.title,
442        metadata: decode_optional_json(value.metadata)?,
443        created_at: value.created_at,
444        updated_at: value.updated_at,
445    })
446}
447
448fn decode_history_entry(value: AcpDurableHistoryEntry) -> Result<DurableSessionEventEntry> {
449    Ok(DurableSessionEventEntry {
450        durability: DurableEventKind::Durable,
451        session_id: value.session_id,
452        sequence: value.sequence,
453        timestamp: value.timestamp,
454        event: decode_durable_event(value.event)?,
455    })
456}
457
458pub(crate) fn decode_durable_event(value: AcpDurableEvent) -> Result<DurableSessionEvent> {
459    match value {
460        AcpDurableEvent::AcpDurableSessionUpdate(event) => {
461            let update: SessionUpdate = decode_json(&event.update)?;
462            let mut value = serde_json::to_value(update)?;
463            let object = value
464                .as_object_mut()
465                .ok_or_else(|| anyhow::anyhow!("ACP session update must serialize as an object"))?;
466            let update_type = object
467                .remove("sessionUpdate")
468                .ok_or_else(|| anyhow::anyhow!("ACP session update is missing sessionUpdate"))?;
469            object.insert(String::from("type"), update_type);
470            serde_json::from_value(value).map_err(Into::into)
471        }
472        AcpDurableEvent::AcpDurablePermissionRequest(event) => {
473            let request: RequestPermissionRequest = decode_json(&event.request)?;
474            Ok(DurableSessionEvent::PermissionRequest {
475                request_id: event.request_id,
476                options: request.options,
477                tool_call: request.tool_call,
478                meta: request.meta,
479            })
480        }
481        AcpDurableEvent::AcpDurablePermissionResponse(event) => {
482            let response: RequestPermissionResponse = decode_json(&event.response)?;
483            let status = match event.status.as_str() {
484                "accepted" => PermissionEventStatus::Accepted,
485                "not_pending" => PermissionEventStatus::NotPending,
486                status => anyhow::bail!("invalid permission response event status: {status}"),
487            };
488            let reason = event
489                .reason
490                .as_deref()
491                .map(permission_terminal_reason)
492                .transpose()?;
493            Ok(DurableSessionEvent::PermissionResponse {
494                request_id: event.request_id,
495                outcome: response.outcome,
496                meta: response.meta,
497                status,
498                reason,
499            })
500        }
501    }
502}
503
504fn decode_session_config_response(response: AcpResponse, operation: &str) -> Result<SessionConfig> {
505    match response {
506        AcpResponse::AcpSessionConfigResponse(response) => Ok(SessionConfig {
507            revision: response.revision,
508            options: decode_json(&response.options)?,
509        }),
510        AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
511        other => Err(unexpected_acp_response(operation, other).into()),
512    }
513}
514
515fn normalize_session_capabilities(value: Value) -> Result<SessionCapabilities> {
516    let object = value.as_object().ok_or_else(|| {
517        ClientError::Sidecar(String::from("malformed ACP agentCapabilities JSON"))
518    })?;
519    let prompt = object
520        .get("promptCapabilities")
521        .filter(|value| value.is_object())
522        .cloned();
523    let mcp = object
524        .get("mcpCapabilities")
525        .filter(|value| value.is_object())
526        .cloned();
527    let session = object
528        .get("sessionCapabilities")
529        .filter(|value| value.is_object())
530        .cloned();
531    let extensions = object
532        .iter()
533        .filter(|(key, _)| {
534            !matches!(
535                key.as_str(),
536                "loadSession" | "promptCapabilities" | "mcpCapabilities" | "sessionCapabilities"
537            )
538        })
539        .map(|(key, value)| (key.clone(), value.clone()))
540        .collect();
541    Ok(SessionCapabilities {
542        protocol_version: crate::ACP_PROTOCOL_VERSION,
543        load_session: object
544            .get("loadSession")
545            .and_then(Value::as_bool)
546            .unwrap_or(false),
547        prompt,
548        mcp,
549        session,
550        extensions,
551    })
552}
553
554fn acp_operation_error(error: agentos_protocol::generated::v1::AcpErrorResponse) -> ClientError {
555    ClientError::AcpOperation {
556        code: error.code,
557        message: error.message,
558    }
559}
560
561fn unexpected_acp_response(operation: &str, response: AcpResponse) -> ClientError {
562    ClientError::Sidecar(format!("unexpected response to {operation}: {response:?}"))
563}
564
565fn combine_instructions(additional: Option<&str>, binding_reference: &str) -> Option<String> {
566    let mut parts = Vec::new();
567    if let Some(additional) = additional.map(str::trim).filter(|value| !value.is_empty()) {
568        parts.push(additional.to_string());
569    }
570    let binding_reference = binding_reference.trim();
571    if !binding_reference.is_empty() {
572        parts.push(binding_reference.to_string());
573    }
574    if parts.is_empty() {
575        None
576    } else {
577        Some(parts.join("\n\n"))
578    }
579}
580
581fn build_binding_reference(bindings: &[Bindings]) -> String {
582    if bindings.is_empty() {
583        return String::new();
584    }
585
586    let mut lines = vec![
587        String::from("## Available Host Bindings"),
588        String::new(),
589        String::from("Run `agentos list-bindings` to see all available bindings."),
590        String::new(),
591    ];
592
593    for collection in bindings {
594        lines.push(format!("### {}", collection.name));
595        lines.push(String::new());
596        lines.push(collection.description.clone());
597        lines.push(String::new());
598        for binding in &collection.bindings {
599            let signature = build_binding_flag_signature(&binding.input_schema);
600            let suffix = if signature.is_empty() {
601                String::new()
602            } else {
603                format!(" {signature}")
604            };
605            lines.push(format!(
606                "- `agentos-{} {}{}` — {}",
607                collection.name, binding.name, suffix, binding.description
608            ));
609        }
610        lines.push(String::new());
611        lines.push(format!(
612            "Run `agentos-{} <binding> --help` for details.",
613            collection.name
614        ));
615        lines.push(String::new());
616    }
617
618    lines.join("\n")
619}
620
621fn build_binding_flag_signature(schema: &Value) -> String {
622    describe_binding_flags(schema)
623        .into_iter()
624        .map(|flag| {
625            if flag.required {
626                format!("{} <{}>", flag.name, flag.value_type)
627            } else {
628                format!("[{} <{}>]", flag.name, flag.value_type)
629            }
630        })
631        .collect::<Vec<_>>()
632        .join(" ")
633}
634
635struct BindingFlagDescription {
636    name: String,
637    value_type: String,
638    required: bool,
639}
640
641fn describe_binding_flags(schema: &Value) -> Vec<BindingFlagDescription> {
642    let properties = schema
643        .get("properties")
644        .and_then(Value::as_object)
645        .cloned()
646        .unwrap_or_default();
647    let required = schema
648        .get("required")
649        .and_then(Value::as_array)
650        .map(|items| {
651            items
652                .iter()
653                .filter_map(Value::as_str)
654                .map(str::to_owned)
655                .collect::<BTreeSet<_>>()
656        })
657        .unwrap_or_default();
658
659    properties
660        .into_iter()
661        .map(|(field_name, field_schema)| BindingFlagDescription {
662            name: format!("--{}", camel_to_kebab(&field_name)),
663            value_type: describe_binding_flag_type(&field_schema),
664            required: required.contains(&field_name),
665        })
666        .collect()
667}
668
669fn describe_binding_flag_type(schema: &Value) -> String {
670    match json_schema_type(schema) {
671        Some("array") => {
672            let item_type = schema
673                .get("items")
674                .and_then(json_schema_type)
675                .unwrap_or("string");
676            format!("{item_type}[]")
677        }
678        Some("string") => schema
679            .get("enum")
680            .and_then(Value::as_array)
681            .map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>())
682            .filter(|values| !values.is_empty())
683            .map(|values| values.join("|"))
684            .unwrap_or_else(|| String::from("string")),
685        Some(other) => other.to_string(),
686        None => String::from("string"),
687    }
688}
689
690fn json_schema_type(schema: &Value) -> Option<&str> {
691    schema.get("type").and_then(Value::as_str)
692}
693
694fn camel_to_kebab(value: &str) -> String {
695    let mut output = String::new();
696    for (index, ch) in value.chars().enumerate() {
697        if ch.is_ascii_uppercase() && index > 0 {
698            output.push('-');
699        }
700        output.push(ch.to_ascii_lowercase());
701    }
702    output
703}
704
705// ---------------------------------------------------------------------------
706// Methods
707// ---------------------------------------------------------------------------
708
709impl AgentOs {
710    /// VM-scoped ownership for session RPCs.
711    fn session_ownership(&self) -> wire::OwnershipScope {
712        wire::OwnershipScope::VmOwnership(wire::VmOwnership {
713            connection_id: self.connection_id().to_string(),
714            session_id: self.wire_session_id().to_string(),
715            vm_id: self.vm_id().to_string(),
716        })
717    }
718
719    async fn send_acp_request(
720        &self,
721        request: AcpRequest,
722    ) -> std::result::Result<AcpResponse, ClientError> {
723        let payload = serde_bare::to_vec(&request).map_err(|error| {
724            ClientError::Sidecar(format!("failed to encode ACP request: {error}"))
725        })?;
726        let response = self
727            .transport()
728            .request_wire(
729                self.session_ownership(),
730                wire::RequestPayload::ExtEnvelope(wire::ExtEnvelope {
731                    namespace: ACP_EXTENSION_NAMESPACE.to_string(),
732                    payload,
733                }),
734            )
735            .await?;
736        let envelope = match response {
737            wire::ResponsePayload::ExtEnvelope(envelope) => envelope,
738            wire::ResponsePayload::RejectedResponse(rejected) => {
739                return Err(ClientError::Kernel {
740                    code: rejected.code,
741                    message: rejected.message,
742                });
743            }
744            other => {
745                return Err(ClientError::Sidecar(format!(
746                    "unexpected ACP Ext response: {other:?}"
747                )));
748            }
749        };
750        if envelope.namespace != ACP_EXTENSION_NAMESPACE {
751            return Err(ClientError::Sidecar(format!(
752                "unexpected ACP Ext namespace: {}",
753                envelope.namespace
754            )));
755        }
756        let response: AcpResponse = serde_bare::from_slice(&envelope.payload).map_err(|error| {
757            ClientError::Sidecar(format!("failed to decode ACP response: {error}"))
758        })?;
759        match response {
760            AcpResponse::AcpErrorResponse(error) => Err(ClientError::AcpOperation {
761                code: error.code,
762                message: error.message,
763            }),
764            response => Ok(response),
765        }
766    }
767
768    /// List available agents. A thin forwarder: sends `AcpListAgentsRequest` and
769    /// maps the sidecar's response. The sidecar enumerates the projected
770    /// `/opt/agentos` packages (client parses no manifests). Every such agent is a
771    /// package materialized into the VM at boot, so `installed` is always `true`.
772    pub async fn list_agents(&self) -> Result<Vec<AgentRegistryEntry>> {
773        let response = self
774            .send_acp_request(AcpRequest::AcpListAgentsRequest(AcpListAgentsRequest {
775                reserved: false,
776            }))
777            .await?;
778        let AcpResponse::AcpListAgentsResponse(listed) = response else {
779            return Err(unexpected_acp_response("AcpListAgentsRequest", response).into());
780        };
781        Ok(listed
782            .agents
783            .into_iter()
784            .map(|agent| AgentRegistryEntry {
785                id: agent.id,
786                installed: agent.installed,
787            })
788            .collect())
789    }
790
791    /// Open or restore a durable session. The sidecar owns defaults,
792    /// negotiation, and ACP restore selection; an omitted ID targets `main`.
793    /// This is an idempotent command and returns no metadata. Call
794    /// [`AgentOs::get_session`] when the stored session record is needed.
795    pub async fn open_session(&self, input: OpenSessionInput) -> Result<()> {
796        let caller_instructions = [
797            self.config().additional_instructions.as_deref(),
798            input.additional_instructions.as_deref(),
799        ]
800        .into_iter()
801        .flatten()
802        .map(str::trim)
803        .filter(|value| !value.is_empty())
804        .collect::<Vec<_>>()
805        .join("\n\n");
806        let binding_reference = build_binding_reference(&self.config().bindings);
807        let additional_instructions = combine_instructions(
808            (!caller_instructions.is_empty()).then_some(caller_instructions.as_str()),
809            &binding_reference,
810        );
811        let response = self
812            .send_acp_request(AcpRequest::AcpOpenSessionRequest(AcpOpenSessionRequest {
813                session_id: input.session_id,
814                agent: input.agent,
815                cwd: input.cwd,
816                additional_directories: encode_optional_json(input.additional_directories)?,
817                env: encode_optional_json(input.env)?,
818                mcp_servers: encode_optional_json(input.mcp_servers)?,
819                permission_policy: input.permission_policy.map(permission_policy_wire),
820                skip_os_instructions: input.skip_os_instructions,
821                additional_instructions,
822            }))
823            .await?;
824        match response {
825            AcpResponse::AcpOpenSessionResponse(_) => Ok(()),
826            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
827            other => Err(unexpected_acp_response("AcpOpenSessionRequest", other).into()),
828        }
829    }
830
831    /// Read durable metadata without starting or restoring an ACP adapter.
832    pub async fn get_session(&self, session_id: Option<&str>) -> Result<SessionInfo> {
833        let response = self
834            .send_acp_request(AcpRequest::AcpGetDurableSessionRequest(
835                AcpGetDurableSessionRequest {
836                    session_id: session_id.map(ToOwned::to_owned),
837                },
838            ))
839            .await?;
840        match response {
841            AcpResponse::AcpGetDurableSessionResponse(response) => {
842                decode_session_info(response.session)
843            }
844            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
845            other => Err(unexpected_acp_response("AcpGetDurableSessionRequest", other).into()),
846        }
847    }
848
849    /// Traverse durable sessions by the sidecar-issued keyset cursor. This is a
850    /// SQLite-only operation and never starts an adapter.
851    pub async fn list_sessions(&self, input: ListSessionsInput) -> Result<SessionPage> {
852        let response = self
853            .send_acp_request(AcpRequest::AcpListDurableSessionsRequest(
854                AcpListDurableSessionsRequest {
855                    cursor: input.cursor,
856                    limit: input.limit,
857                },
858            ))
859            .await?;
860        match response {
861            AcpResponse::AcpListDurableSessionsResponse(response) => Ok(SessionPage {
862                sessions: response
863                    .sessions
864                    .into_iter()
865                    .map(decode_session_info)
866                    .collect::<Result<Vec<_>>>()?,
867                next_cursor: response.next_cursor,
868            }),
869            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
870            other => Err(unexpected_acp_response("AcpListDurableSessionsRequest", other).into()),
871        }
872    }
873
874    /// Permanently delete durable metadata and history. `None` targets `main`.
875    pub async fn delete_session(&self, session_id: Option<&str>) -> Result<()> {
876        let response = self
877            .send_acp_request(AcpRequest::AcpDeleteSessionRequest(
878                AcpDeleteSessionRequest {
879                    session_id: session_id.map(ToOwned::to_owned),
880                },
881            ))
882            .await?;
883        match response {
884            AcpResponse::AcpDeleteSessionResponse(_) => Ok(()),
885            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
886            other => Err(unexpected_acp_response("AcpDeleteSessionRequest", other).into()),
887        }
888    }
889
890    /// Release the live adapter while preserving the durable session.
891    pub async fn unload_session(&self, session_id: Option<&str>) -> Result<()> {
892        let response = self
893            .send_acp_request(AcpRequest::AcpUnloadSessionRequest(
894                AcpUnloadSessionRequest {
895                    session_id: session_id.map(ToOwned::to_owned),
896                },
897            ))
898            .await?;
899        match response {
900            AcpResponse::AcpUnloadSessionResponse(_) => Ok(()),
901            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
902            other => Err(unexpected_acp_response("AcpUnloadSessionRequest", other).into()),
903        }
904    }
905
906    /// Durably accept a complete ACP prompt before dispatch. A missing session
907    /// is an error; the sidecar never creates one here or retries uncertain work.
908    pub async fn prompt(&self, input: PromptInput) -> Result<PromptResult> {
909        let response = self
910            .send_acp_request(AcpRequest::AcpPromptRequest(AcpPromptRequest {
911                session_id: input.session_id,
912                idempotency_key: input.idempotency_key,
913                content: encode_json(&input.content)?,
914            }))
915            .await?;
916        match response {
917            AcpResponse::AcpPromptResponse(response) => Ok(PromptResult {
918                session_id: response.session_id,
919                message: decode_optional_json(response.message)?,
920                stop_reason: decode_json(&serde_json::to_string(&response.stop_reason)?)?,
921            }),
922            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
923            other => Err(unexpected_acp_response("AcpPromptRequest", other).into()),
924        }
925    }
926
927    pub async fn cancel_prompt(&self, session_id: Option<&str>) -> Result<CancelPromptStatus> {
928        let response = self
929            .send_acp_request(AcpRequest::AcpCancelPromptRequest(AcpCancelPromptRequest {
930                session_id: session_id.map(ToOwned::to_owned),
931            }))
932            .await?;
933        match response {
934            AcpResponse::AcpCancelPromptResponse(response) => match response.status.as_str() {
935                "cancelled" => Ok(CancelPromptStatus::Cancelled),
936                "no_active_prompt" => Ok(CancelPromptStatus::NoActivePrompt),
937                status => Err(ClientError::Sidecar(format!(
938                    "invalid cancelPrompt status: {status}"
939                ))
940                .into()),
941            },
942            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
943            other => Err(unexpected_acp_response("AcpCancelPromptRequest", other).into()),
944        }
945    }
946
947    pub async fn respond_permission(
948        &self,
949        session_id: &str,
950        request_id: &str,
951        option_id: &str,
952    ) -> Result<PermissionResponseStatus> {
953        let response = self
954            .send_acp_request(AcpRequest::AcpRespondPermissionRequest(
955                AcpRespondPermissionRequest {
956                    session_id: session_id.to_owned(),
957                    request_id: request_id.to_owned(),
958                    option_id: option_id.to_owned(),
959                },
960            ))
961            .await?;
962        match response {
963            AcpResponse::AcpRespondPermissionResponse(response) => match response.status.as_str() {
964                "accepted" => Ok(PermissionResponseStatus::Accepted),
965                "not_pending" => Ok(PermissionResponseStatus::NotPending(
966                    permission_terminal_reason(
967                        response.reason.as_deref().unwrap_or("request_not_found"),
968                    )?,
969                )),
970                status => Err(ClientError::Sidecar(format!(
971                    "invalid respondPermission status: {status}"
972                ))
973                .into()),
974            },
975            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
976            other => Err(unexpected_acp_response("AcpRespondPermissionRequest", other).into()),
977        }
978    }
979
980    /// Read the authoritative SQLite history. ACP message updates deserialize
981    /// through the official protocol-v1 schema crate.
982    pub async fn read_history(&self, input: ReadHistoryInput) -> Result<HistoryPage> {
983        let response = self
984            .send_acp_request(AcpRequest::AcpReadHistoryRequest(AcpReadHistoryRequest {
985                session_id: input.session_id,
986                before: input.before,
987                after: input.after,
988                limit: input.limit,
989            }))
990            .await?;
991        match response {
992            AcpResponse::AcpHistoryPageResponse(response) => Ok(HistoryPage {
993                events: response
994                    .events
995                    .into_iter()
996                    .map(decode_history_entry)
997                    .collect::<Result<Vec<_>>>()?,
998                has_more_before: response.has_more_before,
999                has_more_after: response.has_more_after,
1000            }),
1001            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
1002            other => Err(unexpected_acp_response("AcpReadHistoryRequest", other).into()),
1003        }
1004    }
1005
1006    pub async fn get_session_config(&self, session_id: Option<&str>) -> Result<SessionConfig> {
1007        let response = self
1008            .send_acp_request(AcpRequest::AcpGetSessionConfigRequest(
1009                AcpGetSessionConfigRequest {
1010                    session_id: session_id.map(ToOwned::to_owned),
1011                },
1012            ))
1013            .await?;
1014        decode_session_config_response(response, "AcpGetSessionConfigRequest")
1015    }
1016
1017    pub async fn set_session_config_option(
1018        &self,
1019        session_id: Option<&str>,
1020        config_id: &str,
1021        value: SessionConfigValue,
1022    ) -> Result<SessionConfig> {
1023        let response = self
1024            .send_acp_request(AcpRequest::AcpSetSessionConfigOptionRequest(
1025                AcpSetSessionConfigOptionRequest {
1026                    session_id: session_id.map(ToOwned::to_owned),
1027                    config_id: config_id.to_owned(),
1028                    value: encode_json(&value)?,
1029                },
1030            ))
1031            .await?;
1032        decode_session_config_response(response, "AcpSetSessionConfigOptionRequest")
1033    }
1034
1035    pub async fn get_session_capabilities(
1036        &self,
1037        session_id: Option<&str>,
1038    ) -> Result<Option<SessionCapabilities>> {
1039        let response = self
1040            .send_acp_request(AcpRequest::AcpGetSessionCapabilitiesRequest(
1041                AcpGetSessionCapabilitiesRequest {
1042                    session_id: session_id.map(ToOwned::to_owned),
1043                },
1044            ))
1045            .await?;
1046        match response {
1047            AcpResponse::AcpSessionCapabilitiesResponse(response) => response
1048                .capabilities
1049                .map(|raw| normalize_session_capabilities(decode_json(&raw)?))
1050                .transpose(),
1051            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
1052            other => Err(unexpected_acp_response("AcpGetSessionCapabilitiesRequest", other).into()),
1053        }
1054    }
1055
1056    pub async fn get_session_agent_info(
1057        &self,
1058        session_id: Option<&str>,
1059    ) -> Result<Option<agent_client_protocol_schema::v1::Implementation>> {
1060        let response = self
1061            .send_acp_request(AcpRequest::AcpGetSessionAgentInfoRequest(
1062                AcpGetSessionAgentInfoRequest {
1063                    session_id: session_id.map(ToOwned::to_owned),
1064                },
1065            ))
1066            .await?;
1067        match response {
1068            AcpResponse::AcpSessionAgentInfoResponse(response) => {
1069                decode_optional_json(response.agent_info)
1070            }
1071            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
1072            other => Err(unexpected_acp_response("AcpGetSessionAgentInfoRequest", other).into()),
1073        }
1074    }
1075
1076    /// Subscribe to durable and ephemeral ACP updates for one public session.
1077    /// Omitted session IDs target `main`. Durable entries are emitted only
1078    /// after their SQLite transaction commits; ephemeral entries are deltas and
1079    /// have no durable sequence of their own.
1080    pub fn on_session_event(&self, session_id: Option<&str>) -> DurableSessionEventSubscription {
1081        let session_id = session_id.unwrap_or("main").to_owned();
1082        let rx = self.inner().durable_session_event_tx.subscribe();
1083        let stream = futures::stream::unfold((rx, session_id), |(mut rx, session_id)| async move {
1084            loop {
1085                match rx.recv().await {
1086                    Ok(entry) if session_stream_id(&entry) == session_id => {
1087                        return Some((Ok(entry), (rx, session_id)));
1088                    }
1089                    Ok(_) => continue,
1090                    Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
1091                        return Some((
1092                            Err(SessionSubscriptionError::Lagged { skipped }),
1093                            (rx, session_id),
1094                        ));
1095                    }
1096                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
1097                }
1098            }
1099        });
1100        (Box::pin(stream), Subscription::noop())
1101    }
1102
1103    /// Subscribe to unexpected ACP adapter exits. Pass a session ID to filter
1104    /// to one durable session, or `None` to observe all adapter exits for this
1105    /// VM. Subscribing never starts or restores an adapter.
1106    pub fn on_agent_exit(&self, session_id: Option<&str>) -> AgentExitSubscription {
1107        let session_id = session_id.map(ToOwned::to_owned);
1108        let rx = self.inner().durable_agent_exit_tx.subscribe();
1109        let stream = futures::stream::unfold((rx, session_id), |(mut rx, session_id)| async move {
1110            loop {
1111                match rx.recv().await {
1112                    Ok(event)
1113                        if session_id
1114                            .as_ref()
1115                            .is_none_or(|expected| expected == &event.session_id) =>
1116                    {
1117                        return Some((event, (rx, session_id)));
1118                    }
1119                    Ok(_) => continue,
1120                    Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
1121                        tracing::warn!(skipped, "lagged durable ACP agent-exit subscription");
1122                    }
1123                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
1124                }
1125            }
1126        });
1127        (Box::pin(stream), Subscription::noop())
1128    }
1129}
1130
1131fn permission_terminal_reason(reason: &str) -> Result<PermissionTerminalReason> {
1132    Ok(match reason {
1133        "already_resolved" => PermissionTerminalReason::AlreadyResolved,
1134        "prompt_cancelled" => PermissionTerminalReason::PromptCancelled,
1135        "adapter_exited" => PermissionTerminalReason::AdapterExited,
1136        "session_deleted" => PermissionTerminalReason::SessionDeleted,
1137        "vm_shutdown" => PermissionTerminalReason::VmShutdown,
1138        "request_not_found" => PermissionTerminalReason::RequestNotFound,
1139        other => anyhow::bail!("invalid permission terminal reason: {other}"),
1140    })
1141}