Skip to main content

liminal_server/agents/
server.rs

1//! The connection adapter's agent hooks; kept beside agents to limit shared edits.
2
3use super::{
4    ChannelAccessError, ChannelRegistration, ConnectionServices, ConnectionSubscription,
5    LiminalConnectionServices, ProtocolSchemaId, ServerError,
6};
7use crate::agents::{binding, error::AgentError};
8use crate::auth_lys::session::LysSession;
9
10impl LiminalConnectionServices {
11    pub(super) fn configured_agents(
12        config: &crate::config::ServerConfig,
13        store: std::sync::Arc<dyn liminal::durability::DurableStore>,
14        supervisor: liminal::channel::ChannelSupervisor,
15        console: &std::sync::Arc<crate::console::ConsoleHub>,
16        runtime: Option<tokio::runtime::Handle>,
17    ) -> Result<AgentOwners, ServerError> {
18        let lys = config.auth.as_ref().and_then(|auth| auth.lys.as_ref());
19        let (agents, owners, delivery) = if let Some((lys, bounds, limits)) =
20            lys.and_then(|lys| Some((lys, lys.agents?, lys.drafts?)))
21        {
22            let (placer, seats) = crate::agents::effects::configured(lys, runtime)?;
23            let bindings = crate::agents::waiting::durable::AgentBindings {
24                bounds,
25                network: lys.network_id.clone(),
26                supervisor,
27                device: console.device().into(),
28                placer,
29                seats,
30            };
31            let owners = crate::agents::waiting::durable::ApprovalAgents::open(
32                store,
33                limits.journal,
34                bindings,
35            )
36            .map_err(draft_error)?;
37            let delivery = crate::agents::waiting::AgentDelivery::new(
38                &owners,
39                std::collections::BTreeMap::new(),
40                format!("liminal.{}", console.device()),
41                limits.waiting,
42            )
43            .map_err(draft_error)?;
44            (Some(owners.agents().clone()), Some(owners), Some(delivery))
45        } else {
46            (
47                crate::agents::runtime::configured(
48                    config,
49                    store,
50                    supervisor,
51                    console.device(),
52                    runtime,
53                )?,
54                None,
55                None,
56            )
57        };
58        if let Some(agents) = &agents {
59            agents.bind_console(console)?;
60        }
61        Ok((agents, owners, delivery))
62    }
63    pub(super) fn publish_agent(
64        &self,
65        connection: u64,
66        session: Option<&dyn LysSession>,
67        channel: &str,
68        envelope: &liminal::protocol::MessageEnvelope,
69        key: Option<&str>,
70    ) -> Result<super::PublishOutcome, ServerError> {
71        binding::check(session, crate::auth_lys::HotAction::Publish, channel)?;
72        if let Some(session) = session.filter(|session| session.holder_kind() == Some("agent")) {
73            self.console
74                .tap()
75                .name_station(connection, session.holder())
76                .map_err(|_| AgentError::Delivery("publisher identity projection failed"))?;
77        }
78        let Some((name, leaf)) = binding::subject(channel) else {
79            return ConnectionServices::publish(self, connection, channel, envelope, key);
80        };
81        let agents = self
82            .agents
83            .as_ref()
84            .ok_or(AgentError::Invalid("agent registry absent"))?;
85        binding::decide(
86            session,
87            agents.network(),
88            crate::auth_lys::HotAction::Publish,
89            channel,
90            agents.now()?,
91        )?;
92        if !agents.registry().record(name)?.active() {
93            return Err(AgentError::Transition("agent is retired").into());
94        }
95        if leaf == "inbox" {
96            validate_inbox(agents.bounds(), &envelope.payload)?;
97            self.ensure_agent_channel(channel)?;
98        }
99        self.publish_with_origin(
100            channel,
101            envelope,
102            key,
103            crate::server::self_streams::events::PublishOrigin::Connection { id: connection },
104        )
105        .map(|publication| publication.outcome)
106    }
107
108    /// Executes a pulled agent draft and applies its runtime projection. The
109    /// durable receipt is kept even when projection fails, so an explicit
110    /// replay repairs projection without starting or creating anything twice.
111    ///
112    /// # Errors
113    /// Registry, Lys effect, roster or retained-subject failure.
114    pub fn execute_agent_draft(
115        &self,
116        draft: &lys_pass::drafts::ApprovedDraft,
117    ) -> Result<lys_pass::drafts::Execution, ServerError> {
118        let agents = self
119            .agents
120            .as_ref()
121            .ok_or(AgentError::Invalid("agent registry absent"))?;
122        let receipt = agents.execute_approved(draft)?;
123        match draft.target.action.as_str() {
124            "create_agent" => self.agent_created(&draft.target.id)?,
125            "retire" => self.retire_agent_inbox(&draft.target.id)?,
126            _ => {}
127        }
128        Ok(receipt)
129    }
130
131    fn retire_agent_inbox(&self, name: &str) -> Result<(), ServerError> {
132        let inbox = format!("agents.{name}.inbox");
133        let configured = self
134            .read_roster()
135            .map_err(|error| AgentError::Store(error.to_string()))?
136            .get(&inbox)
137            .cloned();
138        if let Some(configured) = configured {
139            self.quiesce_channel(&inbox, "agent_retired")
140                .map_err(|error| AgentError::Store(error.to_string()))?;
141            configured
142                .handle
143                .close()
144                .map_err(|_| AgentError::Delivery("retired inbox close failed"))?;
145            self.write_roster()
146                .map_err(|error| AgentError::Store(error.to_string()))?
147                .remove(&inbox);
148        }
149        self.console
150            .emit(crate::console::TopologyChange::Service {
151                subject: inbox,
152                present: false,
153            })
154            .map_err(|_| AgentError::Delivery("agent retirement topology failed"))?;
155        Ok(())
156    }
157    /// Installs synthetic approved fixtures on the real store and scheduler.
158    ///
159    /// # Errors
160    /// Returns registry, clock or retained projection failures.
161    #[cfg(any(test, feature = "test-support"))]
162    pub fn with_agent_fixture(
163        mut self,
164        network: &str,
165        placer: std::sync::Arc<dyn crate::services::lifecycle::Placer>,
166    ) -> Result<Self, ServerError> {
167        self.agents = Some(crate::agents::runtime::Agents::open(
168            self.durable_store(),
169            crate::agents::config::AgentsConfig::default(),
170            network.into(),
171            self.cluster.supervisor().clone(),
172            self.console.device(),
173            Some(placer),
174            None,
175        )?);
176        self.agents
177            .as_ref()
178            .ok_or(AgentError::Invalid("agent fixture missing"))?
179            .bind_console(&self.console)?;
180        Ok(self)
181    }
182    /// Gives every console tap attached from now on a ring of `capacity`,
183    /// so a fixture can fill one with a handful of publishes. Call it before
184    /// any tap attaches.
185    #[cfg(any(test, feature = "test-support"))]
186    #[must_use]
187    pub fn with_console_tap_capacity(self, capacity: usize) -> Self {
188        self.console.set_tap_capacity(capacity);
189        self
190    }
191    pub(super) fn subscribe_agent(
192        &self,
193        connection: u64,
194        session: Option<&dyn LysSession>,
195        channel: &str,
196        schemas: &[ProtocolSchemaId],
197        install: Option<liminal::channel::InboxInstall>,
198    ) -> Result<ConnectionSubscription, ServerError> {
199        binding::check(session, crate::auth_lys::HotAction::Subscribe, channel)?;
200        if binding::subject(channel).is_none() {
201            let subscription = ConnectionServices::subscribe(self, channel, schemas, install)?;
202            return self.track_agent_listen(connection, channel, subscription);
203        }
204        let agents = self
205            .agents
206            .as_ref()
207            .ok_or(AgentError::Invalid("[auth.lys.agents] is not configured"))?;
208        self.ensure_agent_channel(channel)?;
209        let id = self
210            .next_subscription_id
211            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
212        let subscription = agents
213            .subscribe(
214                crate::agents::runtime::AgentSubscription {
215                    services: self,
216                    connection,
217                    session,
218                    subject: channel,
219                    schemas,
220                    install,
221                    id,
222                },
223                |install| {
224                    self.open_subscription(channel, schemas, install, None)
225                        .map_err(|error| AgentError::Store(error.to_string()))
226                },
227            )
228            .map_err(ServerError::from)?;
229        self.track_agent_listen(connection, channel, subscription)
230    }
231
232    fn track_agent_listen(
233        &self,
234        connection: u64,
235        subject: &str,
236        subscription: ConnectionSubscription,
237    ) -> Result<ConnectionSubscription, ServerError> {
238        let Some(agents) = &self.agents else {
239            return Ok(subscription);
240        };
241        let ConnectionSubscription {
242            id,
243            channel,
244            stream_id,
245            selected_schema,
246            resource,
247            issued,
248        } = subscription;
249        let resource = agents.track_listen(connection, id, subject, resource)?;
250        Ok(ConnectionSubscription {
251            id,
252            channel,
253            stream_id,
254            selected_schema,
255            resource,
256            issued,
257        })
258    }
259
260    /// Makes an approved station's ordinary inbox available and seeds metadata.
261    /// The draft executor calls this after its durable receipt; it grants nothing.
262    ///
263    /// # Errors
264    /// Missing configuration, unknown agent, roster bound, or metadata failure.
265    pub fn agent_created(&self, name: &str) -> Result<(), ServerError> {
266        self.ensure_agent_channel(&format!("agents.{name}.inbox"))?;
267        self.ensure_agent_channel(&format!("agents.{name}.cancel"))?;
268        self.ensure_agent_channel(&format!("agents.{name}.stream"))?;
269        if let Some(delivery) = &self.agent_delivery {
270            let inbox = self
271                .read_roster()
272                .map_err(|error| AgentError::Store(error.to_string()))?
273                .get(&format!("agents.{name}.inbox"))
274                .map(|channel| channel.handle.clone())
275                .ok_or(AgentError::Invalid("registered inbox absent"))?;
276            delivery.bind_inbox(name, inbox).map_err(draft_error)?;
277        }
278        self.agents
279            .as_ref()
280            .ok_or(AgentError::Invalid("agent registry absent"))?
281            .created(name)?;
282        self.console
283            .emit(crate::console::TopologyChange::Service {
284                subject: format!("agents.{name}.inbox"),
285                present: true,
286            })
287            .map_err(|_| AgentError::Delivery("agent topology change failed"))?;
288        Ok(())
289    }
290
291    fn ensure_agent_channel(&self, channel: &str) -> Result<(), ServerError> {
292        let (name, leaf) = binding::subject(channel).ok_or(AgentError::Invalid("agent subject"))?;
293        let agents = self
294            .agents
295            .as_ref()
296            .ok_or(AgentError::Invalid("agent registry absent"))?;
297        let record = agents.registry().record(name)?;
298        if matches!(leaf, "presence" | "state") {
299            return Ok(());
300        }
301        if !record.active() {
302            return Err(AgentError::Transition("agent is retired").into());
303        }
304        if !matches!(leaf, "inbox" | "cancel" | "stream") {
305            return Ok(());
306        }
307        let schema = serde_json::to_vec(&serde_json::json!({"type":"object"}))
308            .map_err(|_| AgentError::Invalid("inbox schema encoding"))?;
309        self.register_channel(&ChannelRegistration {
310            name: channel.into(),
311            schema_bytes: (leaf == "inbox").then_some(schema),
312            durable: leaf == "inbox",
313        })
314        .map_err(|error| AgentError::Store(error.to_string()))?;
315        Ok(())
316    }
317
318    pub(super) fn agent_channel_admission(
319        &self,
320        channel: &str,
321    ) -> Option<Result<(), ChannelAccessError>> {
322        let (_, leaf) = binding::subject(channel)?;
323        if !matches!(leaf, "presence" | "state" | "inbox" | "cancel" | "stream") {
324            return None;
325        }
326        Some(self.ensure_agent_channel(channel).map_err(|error| {
327            ChannelAccessError::RosterUnavailable {
328                message: error.to_string(),
329            }
330        }))
331    }
332
333    /// Keeps agent station metadata in the topology published by the ordinary hub.
334    ///
335    /// # Errors
336    /// A topology or registry record cannot be encoded.
337    pub fn agent_topology_payload<'a>(
338        &self,
339        subject: &str,
340        payload: &'a [u8],
341    ) -> Result<std::borrow::Cow<'a, [u8]>, crate::console::ConsoleError> {
342        let Some(agents) = &self.agents else {
343            return Ok(std::borrow::Cow::Borrowed(payload));
344        };
345        if subject != crate::console::TOPOLOGY_SUBJECT {
346            return Ok(std::borrow::Cow::Borrowed(payload));
347        }
348        let bytes = crate::console::topology::with_agents(payload, agents, self.console.device())
349            .map_err(|error| crate::console::ConsoleError::Server {
350            subject: subject.into(),
351            source: error.into(),
352        })?;
353        Ok(std::borrow::Cow::Owned(bytes))
354    }
355}
356
357fn validate_inbox(
358    bounds: &crate::agents::config::AgentsConfig,
359    payload: &[u8],
360) -> Result<(), AgentError> {
361    if payload.len() > bounds.max_event_bytes {
362        return Err(AgentError::Bound {
363            bound: "max_event_bytes",
364            limit: bounds.max_event_bytes,
365            count: payload.len(),
366        });
367    }
368    let value: serde_json::Value =
369        serde_json::from_slice(payload).map_err(|_| AgentError::Invalid("inbox JSON"))?;
370    // A prompt is {text}; {command} is a harness command; {config} and {session} read or change
371    // the harness's own options and conversations. Each control holds only text and switches.
372    let control = |value: &serde_json::Value, keys: &[&str]| {
373        value.as_object().is_some_and(|fields| {
374            fields.iter().all(|(key, value)| {
375                keys.contains(&key.as_str()) && (value.is_string() || value.is_boolean())
376            })
377        })
378    };
379    let accepted = value.as_object().is_some_and(|object| {
380        object.len() == 1
381            && (object.get("text").is_some_and(serde_json::Value::is_string)
382                || object
383                    .get("config")
384                    .is_some_and(|c| control(c, &["option", "value"]))
385                || object
386                    .get("command")
387                    .is_some_and(|c| control(c, &["name", "input"]))
388                || object
389                    .get("session")
390                    .is_some_and(|c| control(c, &["list", "new", "open"])))
391    });
392    if !accepted {
393        return Err(AgentError::Invalid(
394            "inbox accepts exactly one of {text}, {command}, {config} or {session}",
395        ));
396    }
397    Ok(())
398}
399
400#[cfg(test)]
401#[path = "server_tests.rs"]
402mod tests;
403
404type AgentOwners = (
405    Option<std::sync::Arc<crate::agents::runtime::Agents>>,
406    Option<std::sync::Arc<crate::agents::waiting::durable::ApprovalAgents>>,
407    Option<std::sync::Arc<crate::agents::waiting::AgentDelivery>>,
408);
409fn draft_error(error: crate::auth_lys::drafts::DraftError) -> ServerError {
410    error.into()
411}
412impl From<crate::auth_lys::drafts::DraftError> for ServerError {
413    fn from(error: crate::auth_lys::drafts::DraftError) -> Self {
414        Self::ConfigValidation {
415            message: error.to_string(),
416        }
417    }
418}