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::ResolvedHostFunctions;
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>, host_function_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 host_function_reference = host_function_reference.trim();
571    if !host_function_reference.is_empty() {
572        parts.push(host_function_reference.to_string());
573    }
574    if parts.is_empty() {
575        None
576    } else {
577        Some(parts.join("\n\n"))
578    }
579}
580
581fn build_host_function_reference(host_functions: &[ResolvedHostFunctions]) -> String {
582    if host_functions.is_empty() {
583        return String::new();
584    }
585
586    let mut lines = vec![
587        String::from("## Available Host Functions"),
588        String::new(),
589        String::from("Run `agentos list-host-functions` to see all available host functions."),
590        String::new(),
591    ];
592
593    for collection in host_functions {
594        lines.push(format!("### {}", collection.name));
595        lines.push(String::new());
596        for host_function in &collection.functions {
597            let signature = build_host_function_flag_signature(&host_function.input_schema);
598            let suffix = if signature.is_empty() {
599                String::new()
600            } else {
601                format!(" {signature}")
602            };
603            lines.push(if host_function.description.is_empty() {
604                format!(
605                    "- `agentos-{} {}{}`",
606                    collection.name, host_function.name, suffix
607                )
608            } else {
609                format!(
610                    "- `agentos-{} {}{}` - {}",
611                    collection.name, host_function.name, suffix, host_function.description
612                )
613            });
614        }
615        lines.push(String::new());
616        lines.push(format!(
617            "Run `agentos-{} <function> --help` for details.",
618            collection.name
619        ));
620        lines.push(String::new());
621    }
622
623    lines.join("\n")
624}
625
626fn build_host_function_flag_signature(schema: &Value) -> String {
627    describe_host_function_flags(schema)
628        .into_iter()
629        .map(|flag| {
630            if flag.required {
631                format!("{} <{}>", flag.name, flag.value_type)
632            } else {
633                format!("[{} <{}>]", flag.name, flag.value_type)
634            }
635        })
636        .collect::<Vec<_>>()
637        .join(" ")
638}
639
640struct HostFunctionFlagDescription {
641    name: String,
642    value_type: String,
643    required: bool,
644}
645
646fn describe_host_function_flags(schema: &Value) -> Vec<HostFunctionFlagDescription> {
647    let properties = schema
648        .get("properties")
649        .and_then(Value::as_object)
650        .cloned()
651        .unwrap_or_default();
652    let required = schema
653        .get("required")
654        .and_then(Value::as_array)
655        .map(|items| {
656            items
657                .iter()
658                .filter_map(Value::as_str)
659                .map(str::to_owned)
660                .collect::<BTreeSet<_>>()
661        })
662        .unwrap_or_default();
663
664    properties
665        .into_iter()
666        .map(|(field_name, field_schema)| HostFunctionFlagDescription {
667            name: format!("--{}", camel_to_kebab(&field_name)),
668            value_type: describe_host_function_flag_type(&field_schema),
669            required: required.contains(&field_name),
670        })
671        .collect()
672}
673
674fn describe_host_function_flag_type(schema: &Value) -> String {
675    match json_schema_type(schema) {
676        Some("array") => {
677            let item_type = schema
678                .get("items")
679                .and_then(json_schema_type)
680                .unwrap_or("string");
681            format!("{item_type}[]")
682        }
683        Some("string") => schema
684            .get("enum")
685            .and_then(Value::as_array)
686            .map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>())
687            .filter(|values| !values.is_empty())
688            .map(|values| values.join("|"))
689            .unwrap_or_else(|| String::from("string")),
690        Some(other) => other.to_string(),
691        None => String::from("string"),
692    }
693}
694
695fn json_schema_type(schema: &Value) -> Option<&str> {
696    schema.get("type").and_then(Value::as_str)
697}
698
699fn camel_to_kebab(value: &str) -> String {
700    let mut output = String::new();
701    for (index, ch) in value.chars().enumerate() {
702        if ch.is_ascii_uppercase() && index > 0 {
703            output.push('-');
704        }
705        output.push(ch.to_ascii_lowercase());
706    }
707    output
708}
709
710// ---------------------------------------------------------------------------
711// Methods
712// ---------------------------------------------------------------------------
713
714impl AgentOs {
715    /// VM-scoped ownership for session RPCs.
716    fn session_ownership(&self) -> wire::OwnershipScope {
717        wire::OwnershipScope::VmOwnership(wire::VmOwnership {
718            connection_id: self.connection_id().to_string(),
719            session_id: self.wire_session_id().to_string(),
720            vm_id: self.vm_id().to_string(),
721        })
722    }
723
724    async fn send_acp_request(
725        &self,
726        request: AcpRequest,
727    ) -> std::result::Result<AcpResponse, ClientError> {
728        let payload = serde_bare::to_vec(&request).map_err(|error| {
729            ClientError::Sidecar(format!("failed to encode ACP request: {error}"))
730        })?;
731        let response = self
732            .transport()
733            .request_wire(
734                self.session_ownership(),
735                wire::RequestPayload::ExtEnvelope(wire::ExtEnvelope {
736                    namespace: ACP_EXTENSION_NAMESPACE.to_string(),
737                    payload,
738                }),
739            )
740            .await?;
741        let envelope = match response {
742            wire::ResponsePayload::ExtEnvelope(envelope) => envelope,
743            wire::ResponsePayload::RejectedResponse(rejected) => {
744                return Err(ClientError::Kernel {
745                    code: rejected.code,
746                    message: rejected.message,
747                });
748            }
749            other => {
750                return Err(ClientError::Sidecar(format!(
751                    "unexpected ACP Ext response: {other:?}"
752                )));
753            }
754        };
755        if envelope.namespace != ACP_EXTENSION_NAMESPACE {
756            return Err(ClientError::Sidecar(format!(
757                "unexpected ACP Ext namespace: {}",
758                envelope.namespace
759            )));
760        }
761        let response: AcpResponse = serde_bare::from_slice(&envelope.payload).map_err(|error| {
762            ClientError::Sidecar(format!("failed to decode ACP response: {error}"))
763        })?;
764        match response {
765            AcpResponse::AcpErrorResponse(error) => Err(ClientError::AcpOperation {
766                code: error.code,
767                message: error.message,
768            }),
769            response => Ok(response),
770        }
771    }
772
773    /// List available agents. A thin forwarder: sends `AcpListAgentsRequest` and
774    /// maps the sidecar's response. The sidecar enumerates the projected
775    /// `/opt/agentos` packages (client parses no manifests). Every such agent is a
776    /// package materialized into the VM at boot, so `installed` is always `true`.
777    pub async fn list_agents(&self) -> Result<Vec<AgentRegistryEntry>> {
778        let response = self
779            .send_acp_request(AcpRequest::AcpListAgentsRequest(AcpListAgentsRequest {
780                reserved: false,
781            }))
782            .await?;
783        let AcpResponse::AcpListAgentsResponse(listed) = response else {
784            return Err(unexpected_acp_response("AcpListAgentsRequest", response).into());
785        };
786        Ok(listed
787            .agents
788            .into_iter()
789            .map(|agent| AgentRegistryEntry {
790                id: agent.id,
791                installed: agent.installed,
792            })
793            .collect())
794    }
795
796    /// Open or restore a durable session. The sidecar owns defaults,
797    /// negotiation, and ACP restore selection; an omitted ID targets `main`.
798    /// This is an idempotent command and returns no metadata. Call
799    /// [`AgentOs::get_session`] when the stored session record is needed.
800    pub async fn open_session(&self, input: OpenSessionInput) -> Result<()> {
801        let caller_instructions = [
802            self.config().additional_instructions.as_deref(),
803            input.additional_instructions.as_deref(),
804        ]
805        .into_iter()
806        .flatten()
807        .map(str::trim)
808        .filter(|value| !value.is_empty())
809        .collect::<Vec<_>>()
810        .join("\n\n");
811        let host_function_reference = build_host_function_reference(self.host_functions());
812        let additional_instructions = combine_instructions(
813            (!caller_instructions.is_empty()).then_some(caller_instructions.as_str()),
814            &host_function_reference,
815        );
816        let response = self
817            .send_acp_request(AcpRequest::AcpOpenSessionRequest(AcpOpenSessionRequest {
818                session_id: input.session_id,
819                agent: input.agent,
820                cwd: input.cwd,
821                additional_directories: encode_optional_json(input.additional_directories)?,
822                env: encode_optional_json(input.env)?,
823                mcp_servers: encode_optional_json(input.mcp_servers)?,
824                permission_policy: input.permission_policy.map(permission_policy_wire),
825                skip_os_instructions: input.skip_os_instructions,
826                additional_instructions,
827            }))
828            .await?;
829        match response {
830            AcpResponse::AcpOpenSessionResponse(_) => Ok(()),
831            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
832            other => Err(unexpected_acp_response("AcpOpenSessionRequest", other).into()),
833        }
834    }
835
836    /// Read durable metadata without starting or restoring an ACP adapter.
837    pub async fn get_session(&self, session_id: Option<&str>) -> Result<SessionInfo> {
838        let response = self
839            .send_acp_request(AcpRequest::AcpGetDurableSessionRequest(
840                AcpGetDurableSessionRequest {
841                    session_id: session_id.map(ToOwned::to_owned),
842                },
843            ))
844            .await?;
845        match response {
846            AcpResponse::AcpGetDurableSessionResponse(response) => {
847                decode_session_info(response.session)
848            }
849            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
850            other => Err(unexpected_acp_response("AcpGetDurableSessionRequest", other).into()),
851        }
852    }
853
854    /// Traverse durable sessions by the sidecar-issued keyset cursor. This is a
855    /// SQLite-only operation and never starts an adapter.
856    pub async fn list_sessions(&self, input: ListSessionsInput) -> Result<SessionPage> {
857        let response = self
858            .send_acp_request(AcpRequest::AcpListDurableSessionsRequest(
859                AcpListDurableSessionsRequest {
860                    cursor: input.cursor,
861                    limit: input.limit,
862                },
863            ))
864            .await?;
865        match response {
866            AcpResponse::AcpListDurableSessionsResponse(response) => Ok(SessionPage {
867                sessions: response
868                    .sessions
869                    .into_iter()
870                    .map(decode_session_info)
871                    .collect::<Result<Vec<_>>>()?,
872                next_cursor: response.next_cursor,
873            }),
874            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
875            other => Err(unexpected_acp_response("AcpListDurableSessionsRequest", other).into()),
876        }
877    }
878
879    /// Permanently delete durable metadata and history. `None` targets `main`.
880    pub async fn delete_session(&self, session_id: Option<&str>) -> Result<()> {
881        let response = self
882            .send_acp_request(AcpRequest::AcpDeleteSessionRequest(
883                AcpDeleteSessionRequest {
884                    session_id: session_id.map(ToOwned::to_owned),
885                },
886            ))
887            .await?;
888        match response {
889            AcpResponse::AcpDeleteSessionResponse(_) => Ok(()),
890            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
891            other => Err(unexpected_acp_response("AcpDeleteSessionRequest", other).into()),
892        }
893    }
894
895    /// Release the live adapter while preserving the durable session.
896    pub async fn unload_session(&self, session_id: Option<&str>) -> Result<()> {
897        let response = self
898            .send_acp_request(AcpRequest::AcpUnloadSessionRequest(
899                AcpUnloadSessionRequest {
900                    session_id: session_id.map(ToOwned::to_owned),
901                },
902            ))
903            .await?;
904        match response {
905            AcpResponse::AcpUnloadSessionResponse(_) => Ok(()),
906            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
907            other => Err(unexpected_acp_response("AcpUnloadSessionRequest", other).into()),
908        }
909    }
910
911    /// Durably accept a complete ACP prompt before dispatch. A missing session
912    /// is an error; the sidecar never creates one here or retries uncertain work.
913    pub async fn prompt(&self, input: PromptInput) -> Result<PromptResult> {
914        let response = self
915            .send_acp_request(AcpRequest::AcpPromptRequest(AcpPromptRequest {
916                session_id: input.session_id,
917                idempotency_key: input.idempotency_key,
918                content: encode_json(&input.content)?,
919            }))
920            .await?;
921        match response {
922            AcpResponse::AcpPromptResponse(response) => Ok(PromptResult {
923                session_id: response.session_id,
924                message: decode_optional_json(response.message)?,
925                stop_reason: decode_json(&serde_json::to_string(&response.stop_reason)?)?,
926            }),
927            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
928            other => Err(unexpected_acp_response("AcpPromptRequest", other).into()),
929        }
930    }
931
932    pub async fn cancel_prompt(&self, session_id: Option<&str>) -> Result<CancelPromptStatus> {
933        let response = self
934            .send_acp_request(AcpRequest::AcpCancelPromptRequest(AcpCancelPromptRequest {
935                session_id: session_id.map(ToOwned::to_owned),
936            }))
937            .await?;
938        match response {
939            AcpResponse::AcpCancelPromptResponse(response) => match response.status.as_str() {
940                "cancelled" => Ok(CancelPromptStatus::Cancelled),
941                "no_active_prompt" => Ok(CancelPromptStatus::NoActivePrompt),
942                status => Err(ClientError::Sidecar(format!(
943                    "invalid cancelPrompt status: {status}"
944                ))
945                .into()),
946            },
947            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
948            other => Err(unexpected_acp_response("AcpCancelPromptRequest", other).into()),
949        }
950    }
951
952    pub async fn respond_permission(
953        &self,
954        session_id: &str,
955        request_id: &str,
956        option_id: &str,
957    ) -> Result<PermissionResponseStatus> {
958        let response = self
959            .send_acp_request(AcpRequest::AcpRespondPermissionRequest(
960                AcpRespondPermissionRequest {
961                    session_id: session_id.to_owned(),
962                    request_id: request_id.to_owned(),
963                    option_id: option_id.to_owned(),
964                },
965            ))
966            .await?;
967        match response {
968            AcpResponse::AcpRespondPermissionResponse(response) => match response.status.as_str() {
969                "accepted" => Ok(PermissionResponseStatus::Accepted),
970                "not_pending" => Ok(PermissionResponseStatus::NotPending(
971                    permission_terminal_reason(
972                        response.reason.as_deref().unwrap_or("request_not_found"),
973                    )?,
974                )),
975                status => Err(ClientError::Sidecar(format!(
976                    "invalid respondPermission status: {status}"
977                ))
978                .into()),
979            },
980            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
981            other => Err(unexpected_acp_response("AcpRespondPermissionRequest", other).into()),
982        }
983    }
984
985    /// Read the authoritative SQLite history. ACP message updates deserialize
986    /// through the official protocol-v1 schema crate.
987    pub async fn read_history(&self, input: ReadHistoryInput) -> Result<HistoryPage> {
988        let response = self
989            .send_acp_request(AcpRequest::AcpReadHistoryRequest(AcpReadHistoryRequest {
990                session_id: input.session_id,
991                before: input.before,
992                after: input.after,
993                limit: input.limit,
994            }))
995            .await?;
996        match response {
997            AcpResponse::AcpHistoryPageResponse(response) => Ok(HistoryPage {
998                events: response
999                    .events
1000                    .into_iter()
1001                    .map(decode_history_entry)
1002                    .collect::<Result<Vec<_>>>()?,
1003                has_more_before: response.has_more_before,
1004                has_more_after: response.has_more_after,
1005            }),
1006            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
1007            other => Err(unexpected_acp_response("AcpReadHistoryRequest", other).into()),
1008        }
1009    }
1010
1011    pub async fn get_session_config(&self, session_id: Option<&str>) -> Result<SessionConfig> {
1012        let response = self
1013            .send_acp_request(AcpRequest::AcpGetSessionConfigRequest(
1014                AcpGetSessionConfigRequest {
1015                    session_id: session_id.map(ToOwned::to_owned),
1016                },
1017            ))
1018            .await?;
1019        decode_session_config_response(response, "AcpGetSessionConfigRequest")
1020    }
1021
1022    pub async fn set_session_config_option(
1023        &self,
1024        session_id: Option<&str>,
1025        config_id: &str,
1026        value: SessionConfigValue,
1027    ) -> Result<SessionConfig> {
1028        let response = self
1029            .send_acp_request(AcpRequest::AcpSetSessionConfigOptionRequest(
1030                AcpSetSessionConfigOptionRequest {
1031                    session_id: session_id.map(ToOwned::to_owned),
1032                    config_id: config_id.to_owned(),
1033                    value: encode_json(&value)?,
1034                },
1035            ))
1036            .await?;
1037        decode_session_config_response(response, "AcpSetSessionConfigOptionRequest")
1038    }
1039
1040    pub async fn get_session_capabilities(
1041        &self,
1042        session_id: Option<&str>,
1043    ) -> Result<Option<SessionCapabilities>> {
1044        let response = self
1045            .send_acp_request(AcpRequest::AcpGetSessionCapabilitiesRequest(
1046                AcpGetSessionCapabilitiesRequest {
1047                    session_id: session_id.map(ToOwned::to_owned),
1048                },
1049            ))
1050            .await?;
1051        match response {
1052            AcpResponse::AcpSessionCapabilitiesResponse(response) => response
1053                .capabilities
1054                .map(|raw| normalize_session_capabilities(decode_json(&raw)?))
1055                .transpose(),
1056            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
1057            other => Err(unexpected_acp_response("AcpGetSessionCapabilitiesRequest", other).into()),
1058        }
1059    }
1060
1061    pub async fn get_session_agent_info(
1062        &self,
1063        session_id: Option<&str>,
1064    ) -> Result<Option<agent_client_protocol_schema::v1::Implementation>> {
1065        let response = self
1066            .send_acp_request(AcpRequest::AcpGetSessionAgentInfoRequest(
1067                AcpGetSessionAgentInfoRequest {
1068                    session_id: session_id.map(ToOwned::to_owned),
1069                },
1070            ))
1071            .await?;
1072        match response {
1073            AcpResponse::AcpSessionAgentInfoResponse(response) => {
1074                decode_optional_json(response.agent_info)
1075            }
1076            AcpResponse::AcpErrorResponse(error) => Err(acp_operation_error(error).into()),
1077            other => Err(unexpected_acp_response("AcpGetSessionAgentInfoRequest", other).into()),
1078        }
1079    }
1080
1081    /// Subscribe to durable and ephemeral ACP updates for one public session.
1082    /// Omitted session IDs target `main`. Durable entries are emitted only
1083    /// after their SQLite transaction commits; ephemeral entries are deltas and
1084    /// have no durable sequence of their own.
1085    pub fn on_session_event(&self, session_id: Option<&str>) -> DurableSessionEventSubscription {
1086        let session_id = session_id.unwrap_or("main").to_owned();
1087        let rx = self.inner().durable_session_event_tx.subscribe();
1088        let stream = futures::stream::unfold((rx, session_id), |(mut rx, session_id)| async move {
1089            loop {
1090                match rx.recv().await {
1091                    Ok(entry) if session_stream_id(&entry) == session_id => {
1092                        return Some((Ok(entry), (rx, session_id)));
1093                    }
1094                    Ok(_) => continue,
1095                    Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
1096                        return Some((
1097                            Err(SessionSubscriptionError::Lagged { skipped }),
1098                            (rx, session_id),
1099                        ));
1100                    }
1101                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
1102                }
1103            }
1104        });
1105        (Box::pin(stream), Subscription::noop())
1106    }
1107
1108    /// Subscribe to unexpected ACP adapter exits. Pass a session ID to filter
1109    /// to one durable session, or `None` to observe all adapter exits for this
1110    /// VM. Subscribing never starts or restores an adapter.
1111    pub fn on_agent_exit(&self, session_id: Option<&str>) -> AgentExitSubscription {
1112        let session_id = session_id.map(ToOwned::to_owned);
1113        let rx = self.inner().durable_agent_exit_tx.subscribe();
1114        let stream = futures::stream::unfold((rx, session_id), |(mut rx, session_id)| async move {
1115            loop {
1116                match rx.recv().await {
1117                    Ok(event)
1118                        if session_id
1119                            .as_ref()
1120                            .is_none_or(|expected| expected == &event.session_id) =>
1121                    {
1122                        return Some((event, (rx, session_id)));
1123                    }
1124                    Ok(_) => continue,
1125                    Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
1126                        tracing::warn!(skipped, "lagged durable ACP agent-exit subscription");
1127                    }
1128                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
1129                }
1130            }
1131        });
1132        (Box::pin(stream), Subscription::noop())
1133    }
1134}
1135
1136fn permission_terminal_reason(reason: &str) -> Result<PermissionTerminalReason> {
1137    Ok(match reason {
1138        "already_resolved" => PermissionTerminalReason::AlreadyResolved,
1139        "prompt_cancelled" => PermissionTerminalReason::PromptCancelled,
1140        "adapter_exited" => PermissionTerminalReason::AdapterExited,
1141        "session_deleted" => PermissionTerminalReason::SessionDeleted,
1142        "vm_shutdown" => PermissionTerminalReason::VmShutdown,
1143        "request_not_found" => PermissionTerminalReason::RequestNotFound,
1144        other => anyhow::bail!("invalid permission terminal reason: {other}"),
1145    })
1146}