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(crate::agents::error::NO_REGISTRY))?;
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(crate::agents::error::NO_REGISTRY))?;
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(crate::agents::error::NO_REGISTRY))?;
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(crate::agents::error::NO_REGISTRY))?
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        let owner = match session.holder_kind() {
307            Some("agent") if session.holder() == name => name.to_owned(),
308            Some(crate::agents::open_session::OPEN_KIND) if session.holder() == name => {
309                if name == crate::server::connection::declared_holder::ANONYMOUS {
310                    return Err(AgentError::Invalid(crate::agents::error::ANONYMOUS_SEAT).into());
311                }
312                format!(
313                    "{}{name}",
314                    crate::server::connection::declared_holder::PREFIX
315                )
316            }
317            _ => return Ok(()),
318        };
319        if agents.registry().place_own(name, &owner, agents.now()?)? {
320            self.agent_created(name)?;
321        }
322        Ok(())
323    }
324
325    fn ensure_agent_channel(&self, channel: &str) -> Result<(), ServerError> {
326        let (name, leaf) = binding::subject(channel).ok_or(AgentError::Invalid("agent subject"))?;
327        let agents = self
328            .agents
329            .as_ref()
330            .ok_or(AgentError::Invalid(crate::agents::error::NO_REGISTRY))?;
331        let record = agents
332            .registry()
333            .record(name)
334            .map_err(|error| match error {
335                AgentError::Unknown(name) => AgentError::NoStation(name),
336                other => other,
337            })?;
338        if matches!(leaf, "presence" | "state") {
339            return Ok(());
340        }
341        if !record.active() {
342            return Err(AgentError::Transition("agent is retired").into());
343        }
344        if !matches!(leaf, "inbox" | "cancel" | "stream") {
345            return Ok(());
346        }
347        let schema = serde_json::to_vec(&serde_json::json!({"type":"object"}))
348            .map_err(|_| AgentError::Invalid("inbox schema encoding"))?;
349        self.register_channel(&ChannelRegistration {
350            name: channel.into(),
351            schema_bytes: (leaf == "inbox").then_some(schema),
352            durable: leaf == "inbox",
353        })
354        .map_err(|error| AgentError::Store(error.to_string()))?;
355        Ok(())
356    }
357
358    pub(super) fn agent_channel_admission(
359        &self,
360        operation: crate::server::connection::ChannelOperation,
361        channel: &str,
362    ) -> Option<Result<(), ChannelAccessError>> {
363        let (name, leaf) = binding::subject(channel)?;
364        if !matches!(leaf, "presence" | "state" | "inbox" | "cancel" | "stream") {
365            return None;
366        }
367        // ACCESS-013 R1: a subscribe to an inbox with no station yet is
368        // decided where the pass is, in `subscribe_agent`: its own holder
369        // places it there, and any other name is refused there by name.
370        if operation == crate::server::connection::ChannelOperation::Subscribe
371            && leaf == "inbox"
372            && self
373                .agents
374                .as_ref()
375                .is_some_and(|agents| agents.registry().record(name).is_err())
376        {
377            return Some(Ok(()));
378        }
379        Some(self.ensure_agent_channel(channel).map_err(|error| {
380            ChannelAccessError::RosterUnavailable {
381                message: error.to_string(),
382            }
383        }))
384    }
385
386    /// Keeps agent station metadata in the topology published by the ordinary hub.
387    ///
388    /// # Errors
389    /// A topology or registry record cannot be encoded.
390    pub fn agent_topology_payload<'a>(
391        &self,
392        subject: &str,
393        payload: &'a [u8],
394    ) -> Result<std::borrow::Cow<'a, [u8]>, crate::console::ConsoleError> {
395        let Some(agents) = &self.agents else {
396            return Ok(std::borrow::Cow::Borrowed(payload));
397        };
398        if subject != crate::console::TOPOLOGY_SUBJECT {
399            return Ok(std::borrow::Cow::Borrowed(payload));
400        }
401        let bytes = crate::console::topology::with_agents(payload, agents, self.console.device())
402            .map_err(|error| crate::console::ConsoleError::Server {
403            subject: subject.into(),
404            source: error.into(),
405        })?;
406        Ok(std::borrow::Cow::Owned(bytes))
407    }
408}
409
410fn validate_inbox(
411    bounds: &crate::agents::config::AgentsConfig,
412    payload: &[u8],
413) -> Result<(), AgentError> {
414    if payload.len() > bounds.max_event_bytes {
415        return Err(AgentError::Bound {
416            bound: "max_event_bytes",
417            limit: bounds.max_event_bytes,
418            count: payload.len(),
419        });
420    }
421    let value: serde_json::Value =
422        serde_json::from_slice(payload).map_err(|_| AgentError::Invalid("inbox JSON"))?;
423    // A prompt is {text}; {command} is a harness command; {config} and {session} read or change
424    // the harness's own options and conversations. Each control holds only text and switches.
425    let control = |value: &serde_json::Value, keys: &[&str]| {
426        value.as_object().is_some_and(|fields| {
427            fields.iter().all(|(key, value)| {
428                keys.contains(&key.as_str()) && (value.is_string() || value.is_boolean())
429            })
430        })
431    };
432    let accepted = value.as_object().is_some_and(|object| {
433        object.len() == 1
434            && (object.get("text").is_some_and(serde_json::Value::is_string)
435                || object
436                    .get("config")
437                    .is_some_and(|c| control(c, &["option", "value"]))
438                || object
439                    .get("command")
440                    .is_some_and(|c| control(c, &["name", "input"]))
441                || object
442                    .get("session")
443                    .is_some_and(|c| control(c, &["list", "new", "open"])))
444    });
445    if !accepted {
446        return Err(AgentError::Invalid(
447            "inbox accepts exactly one of {text}, {command}, {config} or {session}",
448        ));
449    }
450    Ok(())
451}
452
453#[cfg(test)]
454#[path = "server_tests.rs"]
455mod tests;
456
457type AgentOwners = (
458    Option<std::sync::Arc<crate::agents::runtime::Agents>>,
459    Option<std::sync::Arc<crate::agents::waiting::durable::ApprovalAgents>>,
460    Option<std::sync::Arc<crate::agents::waiting::AgentDelivery>>,
461);
462fn draft_error(error: crate::auth_lys::drafts::DraftError) -> ServerError {
463    error.into()
464}
465impl From<crate::auth_lys::drafts::DraftError> for ServerError {
466    fn from(error: crate::auth_lys::drafts::DraftError) -> Self {
467        Self::ConfigValidation {
468            message: error.to_string(),
469        }
470    }
471}