Skip to main content

liminal_server/agents/
records.rs

1//! Durable agent metadata. Debug deliberately excludes all free text.
2
3use serde::{Deserialize, Serialize};
4
5/// A harness label, never a selector of server behaviour.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub enum AgentHarness {
8    /// Codex with channel wake.
9    #[serde(rename = "cdx")]
10    Cdx,
11    /// Claude Code.
12    #[serde(rename = "claude-code")]
13    ClaudeCode,
14    /// Codex through the ACP host.
15    #[serde(rename = "codex")]
16    Codex,
17    /// Gemini CLI through the ACP host.
18    #[serde(rename = "gemini-cli")]
19    GeminiCli,
20    /// `OpenCode` through the ACP host.
21    #[serde(rename = "opencode")]
22    Opencode,
23    /// Pi through its separately launched host.
24    #[serde(rename = "pi")]
25    Pi,
26    /// A seat's own station, placed by the seat on its first inbox listen
27    /// (ACCESS-013); whatever runs behind the seat is not the server's to name.
28    #[serde(rename = "seat")]
29    Seat,
30}
31
32/// Fields a person approves when creating an agent.
33#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct AgentSpec {
36    /// Its declared harness.
37    pub harness: AgentHarness,
38    /// One sentence stating its job.
39    pub purpose: String,
40    /// The service it builds, when it is a builder.
41    pub service: Option<String>,
42}
43
44/// Events retained forever in the exchange's store.
45#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(
47    tag = "event",
48    content = "detail",
49    rename_all = "snake_case",
50    deny_unknown_fields
51)]
52pub enum AgentEvent {
53    /// A person approved a new station.
54    Create(AgentSpec),
55    /// Lys answered the deliberate start.
56    Start,
57    /// Lys answered the deliberate stop.
58    Stop,
59    /// A verified ticket subscribed its inbox.
60    Present {
61        /// The connection's process id.
62        connection: u64,
63    },
64    /// That connection closed with this stable transport or disconnect name.
65    Gone {
66        /// The closed connection.
67        connection: u64,
68        /// The stable reason, never a transport's payload.
69        reason: String,
70    },
71    /// The host negotiated this session; old histories have no such event.
72    Session {
73        session: String,
74        protocol_version: u8,
75        load_session: bool,
76    },
77    /// An observed host ending never invents resume capability.
78    SessionClosed {
79        exit_status: i32,
80        session_state: String,
81    },
82    /// The station is permanently retired; its history remains.
83    Retire,
84}
85
86/// A transition always names its actor and time.
87#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct AgentTransition {
90    /// The actor proved by the pass, or the server for observed departures.
91    pub holder: String,
92    /// Epoch milliseconds of this transition.
93    pub at_ms: u64,
94    /// What changed.
95    pub change: AgentEvent,
96}
97
98/// One station, as returned after restoration.
99#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct AgentRecord {
102    /// One segment, exactly the Lys agent holder id.
103    pub name: String,
104    /// The profile's harness label.
105    pub harness: AgentHarness,
106    /// One sentence stating its job.
107    pub purpose: String,
108    /// The person who created it.
109    pub owner: String,
110    /// The service it builds.
111    pub service: Option<String>,
112    /// Epoch milliseconds of creation.
113    pub created_at_ms: u64,
114    /// Every transition, oldest first; retirement never removes one.
115    pub transitions: Vec<AgentTransition>,
116}
117
118impl AgentRecord {
119    /// Whether this station still occupies a configured slot.
120    #[must_use]
121    pub fn active(&self) -> bool {
122        self.transitions
123            .last()
124            .is_none_or(|transition| !matches!(transition.change, AgentEvent::Retire))
125    }
126
127    /// Its last observed connection; lifecycle requests do not invent presence.
128    #[must_use]
129    pub fn connection(&self) -> Option<u64> {
130        self.seat().map(|(connection, _)| connection)
131    }
132
133    /// The present connection and the epoch milliseconds it was admitted.
134    #[must_use]
135    pub fn seat(&self) -> Option<(u64, u64)> {
136        self.transitions
137            .iter()
138            .rev()
139            .find_map(|transition| match transition.change {
140                AgentEvent::Present { connection } => Some(Some((connection, transition.at_ms))),
141                AgentEvent::Gone { .. } | AgentEvent::Retire => Some(None),
142                _ => None,
143            })
144            .flatten()
145    }
146}
147
148impl std::fmt::Debug for AgentRecord {
149    fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        out.debug_struct("AgentRecord")
151            .field("harness", &self.harness)
152            .field("transitions", &self.transitions.len())
153            .finish_non_exhaustive()
154    }
155}
156
157/// Immutable prepared words, supplied only by the approved-draft executor.
158#[derive(Clone, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct AgentAct {
161    /// Name of the placed agent.
162    pub name: String,
163    /// The original verified holder, kept in the immutable draft.
164    pub holder: String,
165    /// The caller's responsible person, proved by Lys; never a request override.
166    pub owner: String,
167    /// Preparation time, in epoch milliseconds.
168    pub at_ms: u64,
169    /// The deliberate event.
170    pub change: AgentEvent,
171}
172
173impl AgentAct {
174    /// Prepares immutable words from verified claims. An agent's builders
175    /// count against its responsible person, so changing creator agent ids
176    /// cannot evade the person's configured bound.
177    ///
178    /// # Errors
179    /// The verified holder is neither a person nor an agent with a responsible person.
180    pub fn prepare(
181        name: String,
182        change: AgentEvent,
183        pass: &lys_pass::VerifiedPass,
184        at_ms: u64,
185    ) -> Result<Self, super::error::AgentError> {
186        let holder = &pass.claims().holder;
187        let owner = match holder.kind.as_str() {
188            "person" => holder.id.as_str(),
189            "agent" => holder
190                .responsible
191                .as_deref()
192                .ok_or(super::error::AgentError::Invalid(
193                    "agent responsible person",
194                ))?,
195            _ => {
196                return Err(super::error::AgentError::Invalid(
197                    "agent creation holder kind",
198                ));
199            }
200        };
201        if owner.is_empty() {
202            return Err(super::error::AgentError::Invalid(
203                "agent responsible person",
204            ));
205        }
206        Ok(Self {
207            name,
208            holder: holder.id.clone(),
209            owner: owner.into(),
210            at_ms,
211            change,
212        })
213    }
214}