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.place_own_station(session, channel)?;
209        self.ensure_agent_channel(channel)?;
210        let id = self
211            .next_subscription_id
212            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
213        let subscription = agents
214            .subscribe(
215                crate::agents::runtime::AgentSubscription {
216                    services: self,
217                    connection,
218                    session,
219                    subject: channel,
220                    schemas,
221                    install,
222                    id,
223                },
224                |install| {
225                    self.open_subscription(channel, schemas, install, None)
226                        .map_err(|error| AgentError::Store(error.to_string()))
227                },
228            )
229            .map_err(ServerError::from)?;
230        self.track_agent_listen(connection, channel, subscription)
231    }
232
233    fn track_agent_listen(
234        &self,
235        connection: u64,
236        subject: &str,
237        subscription: ConnectionSubscription,
238    ) -> Result<ConnectionSubscription, ServerError> {
239        let Some(agents) = &self.agents else {
240            return Ok(subscription);
241        };
242        let ConnectionSubscription {
243            id,
244            channel,
245            stream_id,
246            selected_schema,
247            resource,
248            issued,
249        } = subscription;
250        let resource = agents.track_listen(connection, id, subject, resource)?;
251        Ok(ConnectionSubscription {
252            id,
253            channel,
254            stream_id,
255            selected_schema,
256            resource,
257            issued,
258        })
259    }
260
261    /// Makes an approved station's ordinary inbox available and seeds metadata.
262    /// The draft executor calls this after its durable receipt; it grants nothing.
263    ///
264    /// # Errors
265    /// Missing configuration, unknown agent, roster bound, or metadata failure.
266    pub fn agent_created(&self, name: &str) -> Result<(), ServerError> {
267        self.ensure_agent_channel(&format!("agents.{name}.inbox"))?;
268        self.ensure_agent_channel(&format!("agents.{name}.cancel"))?;
269        self.ensure_agent_channel(&format!("agents.{name}.stream"))?;
270        if let Some(delivery) = &self.agent_delivery {
271            let inbox = self
272                .read_roster()
273                .map_err(|error| AgentError::Store(error.to_string()))?
274                .get(&format!("agents.{name}.inbox"))
275                .map(|channel| channel.handle.clone())
276                .ok_or(AgentError::Invalid("registered inbox absent"))?;
277            delivery.bind_inbox(name, inbox).map_err(draft_error)?;
278        }
279        self.agents
280            .as_ref()
281            .ok_or(AgentError::Invalid("agent registry absent"))?
282            .created(name)?;
283        self.console
284            .emit(crate::console::TopologyChange::Service {
285                subject: format!("agents.{name}.inbox"),
286                present: true,
287            })
288            .map_err(|_| AgentError::Delivery("agent topology change failed"))?;
289        Ok(())
290    }
291
292    /// ACCESS-013 R1: a verified agent listening its own inbox with no
293    /// station places it, then makes its channels as an approved create does.
294    /// The grant decision already ran (`binding::check`), so this never widens
295    /// what the pass may do.
296    fn place_own_station(
297        &self,
298        session: Option<&dyn LysSession>,
299        channel: &str,
300    ) -> Result<(), ServerError> {
301        let (Some(session), Some((name, "inbox")), Some(agents)) =
302            (session, binding::subject(channel), self.agents.as_ref())
303        else {
304            return Ok(());
305        };
306        if session.holder_kind() != Some("agent") || session.holder() != name {
307            return Ok(());
308        }
309        if agents.registry().place_own(name, agents.now()?)? {
310            self.agent_created(name)?;
311        }
312        Ok(())
313    }
314
315    fn ensure_agent_channel(&self, channel: &str) -> Result<(), ServerError> {
316        let (name, leaf) = binding::subject(channel).ok_or(AgentError::Invalid("agent subject"))?;
317        let agents = self
318            .agents
319            .as_ref()
320            .ok_or(AgentError::Invalid("agent registry absent"))?;
321        let record = agents.registry().record(name)?;
322        if matches!(leaf, "presence" | "state") {
323            return Ok(());
324        }
325        if !record.active() {
326            return Err(AgentError::Transition("agent is retired").into());
327        }
328        if !matches!(leaf, "inbox" | "cancel" | "stream") {
329            return Ok(());
330        }
331        let schema = serde_json::to_vec(&serde_json::json!({"type":"object"}))
332            .map_err(|_| AgentError::Invalid("inbox schema encoding"))?;
333        self.register_channel(&ChannelRegistration {
334            name: channel.into(),
335            schema_bytes: (leaf == "inbox").then_some(schema),
336            durable: leaf == "inbox",
337        })
338        .map_err(|error| AgentError::Store(error.to_string()))?;
339        Ok(())
340    }
341
342    pub(super) fn agent_channel_admission(
343        &self,
344        operation: crate::server::connection::ChannelOperation,
345        channel: &str,
346    ) -> Option<Result<(), ChannelAccessError>> {
347        let (name, leaf) = binding::subject(channel)?;
348        if !matches!(leaf, "presence" | "state" | "inbox" | "cancel" | "stream") {
349            return None;
350        }
351        // ACCESS-013 R1: a subscribe to an inbox with no station yet is
352        // decided where the pass is, in `subscribe_agent`: its own holder
353        // places it there, and any other name is refused there by name.
354        if operation == crate::server::connection::ChannelOperation::Subscribe
355            && leaf == "inbox"
356            && self
357                .agents
358                .as_ref()
359                .is_some_and(|agents| agents.registry().record(name).is_err())
360        {
361            return Some(Ok(()));
362        }
363        Some(self.ensure_agent_channel(channel).map_err(|error| {
364            ChannelAccessError::RosterUnavailable {
365                message: error.to_string(),
366            }
367        }))
368    }
369
370    /// Keeps agent station metadata in the topology published by the ordinary hub.
371    ///
372    /// # Errors
373    /// A topology or registry record cannot be encoded.
374    pub fn agent_topology_payload<'a>(
375        &self,
376        subject: &str,
377        payload: &'a [u8],
378    ) -> Result<std::borrow::Cow<'a, [u8]>, crate::console::ConsoleError> {
379        let Some(agents) = &self.agents else {
380            return Ok(std::borrow::Cow::Borrowed(payload));
381        };
382        if subject != crate::console::TOPOLOGY_SUBJECT {
383            return Ok(std::borrow::Cow::Borrowed(payload));
384        }
385        let bytes = crate::console::topology::with_agents(payload, agents, self.console.device())
386            .map_err(|error| crate::console::ConsoleError::Server {
387            subject: subject.into(),
388            source: error.into(),
389        })?;
390        Ok(std::borrow::Cow::Owned(bytes))
391    }
392}
393
394fn validate_inbox(
395    bounds: &crate::agents::config::AgentsConfig,
396    payload: &[u8],
397) -> Result<(), AgentError> {
398    if payload.len() > bounds.max_event_bytes {
399        return Err(AgentError::Bound {
400            bound: "max_event_bytes",
401            limit: bounds.max_event_bytes,
402            count: payload.len(),
403        });
404    }
405    let value: serde_json::Value =
406        serde_json::from_slice(payload).map_err(|_| AgentError::Invalid("inbox JSON"))?;
407    // A prompt is {text}; {command} is a harness command; {config} and {session} read or change
408    // the harness's own options and conversations. Each control holds only text and switches.
409    let control = |value: &serde_json::Value, keys: &[&str]| {
410        value.as_object().is_some_and(|fields| {
411            fields.iter().all(|(key, value)| {
412                keys.contains(&key.as_str()) && (value.is_string() || value.is_boolean())
413            })
414        })
415    };
416    let accepted = value.as_object().is_some_and(|object| {
417        object.len() == 1
418            && (object.get("text").is_some_and(serde_json::Value::is_string)
419                || object
420                    .get("config")
421                    .is_some_and(|c| control(c, &["option", "value"]))
422                || object
423                    .get("command")
424                    .is_some_and(|c| control(c, &["name", "input"]))
425                || object
426                    .get("session")
427                    .is_some_and(|c| control(c, &["list", "new", "open"])))
428    });
429    if !accepted {
430        return Err(AgentError::Invalid(
431            "inbox accepts exactly one of {text}, {command}, {config} or {session}",
432        ));
433    }
434    Ok(())
435}
436
437#[cfg(test)]
438#[path = "server_tests.rs"]
439mod tests;
440
441type AgentOwners = (
442    Option<std::sync::Arc<crate::agents::runtime::Agents>>,
443    Option<std::sync::Arc<crate::agents::waiting::durable::ApprovalAgents>>,
444    Option<std::sync::Arc<crate::agents::waiting::AgentDelivery>>,
445);
446fn draft_error(error: crate::auth_lys::drafts::DraftError) -> ServerError {
447    error.into()
448}
449impl From<crate::auth_lys::drafts::DraftError> for ServerError {
450    fn from(error: crate::auth_lys::drafts::DraftError) -> Self {
451        Self::ConfigValidation {
452            message: error.to_string(),
453        }
454    }
455}