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