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            check_sender(&envelope.payload, session.map(LysSession::holder))?;
98            self.ensure_agent_channel(channel)?;
99        }
100        self.publish_with_origin(
101            channel,
102            envelope,
103            key,
104            crate::server::self_streams::events::PublishOrigin::Connection { id: connection },
105        )
106        .map(|publication| publication.outcome)
107    }
108
109    /// Executes a pulled agent draft and applies its runtime projection. The
110    /// durable receipt is kept even when projection fails, so an explicit
111    /// replay repairs projection without starting or creating anything twice.
112    ///
113    /// # Errors
114    /// Registry, Lys effect, roster or retained-subject failure.
115    pub fn execute_agent_draft(
116        &self,
117        draft: &lys_pass::drafts::ApprovedDraft,
118    ) -> Result<lys_pass::drafts::Execution, ServerError> {
119        let agents = self
120            .agents
121            .as_ref()
122            .ok_or(AgentError::Invalid(crate::agents::error::NO_REGISTRY))?;
123        let receipt = agents.execute_approved(draft)?;
124        match draft.target.action.as_str() {
125            "create_agent" => self.agent_created(&draft.target.id)?,
126            "retire" => self.retire_agent_inbox(&draft.target.id)?,
127            _ => {}
128        }
129        Ok(receipt)
130    }
131
132    fn retire_agent_inbox(&self, name: &str) -> Result<(), ServerError> {
133        let inbox = format!("agents.{name}.inbox");
134        let configured = self
135            .read_roster()
136            .map_err(|error| AgentError::Store(error.to_string()))?
137            .get(&inbox)
138            .cloned();
139        if let Some(configured) = configured {
140            self.quiesce_channel(&inbox, "agent_retired")
141                .map_err(|error| AgentError::Store(error.to_string()))?;
142            configured
143                .handle
144                .close()
145                .map_err(|_| AgentError::Delivery("retired inbox close failed"))?;
146            self.write_roster()
147                .map_err(|error| AgentError::Store(error.to_string()))?
148                .remove(&inbox);
149        }
150        self.console
151            .emit(crate::console::TopologyChange::Service {
152                subject: inbox,
153                present: false,
154            })
155            .map_err(|_| AgentError::Delivery("agent retirement topology failed"))?;
156        Ok(())
157    }
158    /// Installs synthetic approved fixtures on the real store and scheduler.
159    ///
160    /// # Errors
161    /// Returns registry, clock or retained projection failures.
162    #[cfg(any(test, feature = "test-support"))]
163    pub fn with_agent_fixture(
164        mut self,
165        network: &str,
166        placer: std::sync::Arc<dyn crate::services::lifecycle::Placer>,
167    ) -> Result<Self, ServerError> {
168        self.agents = Some(crate::agents::runtime::Agents::open(
169            self.durable_store(),
170            crate::agents::config::AgentsConfig::default(),
171            network.into(),
172            self.cluster.supervisor().clone(),
173            self.console.device(),
174            Some(placer),
175            None,
176        )?);
177        self.agents
178            .as_ref()
179            .ok_or(AgentError::Invalid("agent fixture missing"))?
180            .bind_console(&self.console)?;
181        Ok(self)
182    }
183    /// Gives every console tap attached from now on a ring of `capacity`,
184    /// so a fixture can fill one with a handful of publishes. Call it before
185    /// any tap attaches.
186    #[cfg(any(test, feature = "test-support"))]
187    #[must_use]
188    pub fn with_console_tap_capacity(self, capacity: usize) -> Self {
189        self.console.set_tap_capacity(capacity);
190        self
191    }
192    pub(super) fn subscribe_agent(
193        &self,
194        connection: u64,
195        session: Option<&dyn LysSession>,
196        channel: &str,
197        schemas: &[ProtocolSchemaId],
198        install: Option<liminal::channel::InboxInstall>,
199    ) -> Result<ConnectionSubscription, ServerError> {
200        binding::check(session, crate::auth_lys::HotAction::Subscribe, channel)?;
201        if binding::subject(channel).is_none() {
202            let subscription = ConnectionServices::subscribe(self, channel, schemas, install)?;
203            return self.track_agent_listen(connection, channel, subscription);
204        }
205        let agents = self
206            .agents
207            .as_ref()
208            .ok_or(AgentError::Invalid(crate::agents::error::NO_REGISTRY))?;
209        self.place_own_station(session, channel)?;
210        self.ensure_agent_channel(channel)?;
211        let id = self
212            .next_subscription_id
213            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
214        let subscription = agents
215            .subscribe(
216                crate::agents::runtime::AgentSubscription {
217                    services: self,
218                    connection,
219                    session,
220                    subject: channel,
221                    schemas,
222                    install,
223                    id,
224                },
225                |install| {
226                    self.open_subscription(channel, schemas, install, None)
227                        .map_err(|error| AgentError::Store(error.to_string()))
228                },
229            )
230            .map_err(ServerError::from)?;
231        self.track_agent_listen(connection, channel, subscription)
232    }
233
234    fn track_agent_listen(
235        &self,
236        connection: u64,
237        subject: &str,
238        subscription: ConnectionSubscription,
239    ) -> Result<ConnectionSubscription, ServerError> {
240        let Some(agents) = &self.agents else {
241            return Ok(subscription);
242        };
243        let ConnectionSubscription {
244            id,
245            channel,
246            stream_id,
247            selected_schema,
248            resource,
249            issued,
250        } = subscription;
251        let resource = agents.track_listen(connection, id, subject, resource)?;
252        Ok(ConnectionSubscription {
253            id,
254            channel,
255            stream_id,
256            selected_schema,
257            resource,
258            issued,
259        })
260    }
261
262    /// Makes an approved station's ordinary inbox available and seeds metadata.
263    /// The draft executor calls this after its durable receipt; it grants nothing.
264    ///
265    /// # Errors
266    /// Missing configuration, unknown agent, roster bound, or metadata failure.
267    pub fn agent_created(&self, name: &str) -> Result<(), ServerError> {
268        self.ensure_agent_channel(&format!("agents.{name}.inbox"))?;
269        self.ensure_agent_channel(&format!("agents.{name}.cancel"))?;
270        self.ensure_agent_channel(&format!("agents.{name}.stream"))?;
271        if let Some(delivery) = &self.agent_delivery {
272            let inbox = self
273                .read_roster()
274                .map_err(|error| AgentError::Store(error.to_string()))?
275                .get(&format!("agents.{name}.inbox"))
276                .map(|channel| channel.handle.clone())
277                .ok_or(AgentError::Invalid("registered inbox absent"))?;
278            delivery.bind_inbox(name, inbox).map_err(draft_error)?;
279        }
280        self.agents
281            .as_ref()
282            .ok_or(AgentError::Invalid(crate::agents::error::NO_REGISTRY))?
283            .created(name)?;
284        self.console
285            .emit(crate::console::TopologyChange::Service {
286                subject: format!("agents.{name}.inbox"),
287                present: true,
288            })
289            .map_err(|_| AgentError::Delivery("agent topology change failed"))?;
290        Ok(())
291    }
292
293    /// ACCESS-013 R1: a verified agent listening its own inbox with no
294    /// station places it, then makes its channels as an approved create does.
295    /// The grant decision already ran (`binding::check`), so this never widens
296    /// what the pass may do.
297    pub(super) fn place_own_station(
298        &self,
299        session: Option<&dyn LysSession>,
300        channel: &str,
301    ) -> Result<(), ServerError> {
302        let (Some(session), Some((name, "inbox")), Some(agents)) =
303            (session, binding::subject(channel), self.agents.as_ref())
304        else {
305            return Ok(());
306        };
307        let owner = match session.holder_kind() {
308            Some("agent") if session.holder() == name => name.to_owned(),
309            Some(crate::agents::open_session::OPEN_KIND) if session.holder() == name => {
310                if name == crate::server::connection::declared_holder::ANONYMOUS {
311                    return Err(AgentError::Invalid(crate::agents::error::ANONYMOUS_SEAT).into());
312                }
313                format!(
314                    "{}{name}",
315                    crate::server::connection::declared_holder::PREFIX
316                )
317            }
318            _ => return Ok(()),
319        };
320        if agents.registry().place_own(name, &owner, agents.now()?)? {
321            self.agent_created(name)?;
322        }
323        Ok(())
324    }
325
326    fn ensure_agent_channel(&self, channel: &str) -> Result<(), ServerError> {
327        let (name, leaf) = binding::subject(channel).ok_or(AgentError::Invalid("agent subject"))?;
328        let agents = self
329            .agents
330            .as_ref()
331            .ok_or(AgentError::Invalid(crate::agents::error::NO_REGISTRY))?;
332        let record = agents
333            .registry()
334            .record(name)
335            .map_err(|error| match error {
336                AgentError::Unknown(name) => AgentError::NoStation(name),
337                other => other,
338            })?;
339        if matches!(leaf, "presence" | "state") {
340            return Ok(());
341        }
342        if !record.active() {
343            return Err(AgentError::Transition("agent is retired").into());
344        }
345        if !matches!(leaf, "inbox" | "cancel" | "stream") {
346            return Ok(());
347        }
348        let schema = serde_json::to_vec(&serde_json::json!({"type":"object"}))
349            .map_err(|_| AgentError::Invalid("inbox schema encoding"))?;
350        self.register_channel(&ChannelRegistration {
351            name: channel.into(),
352            schema_bytes: (leaf == "inbox").then_some(schema),
353            durable: leaf == "inbox",
354        })
355        .map_err(|error| AgentError::Store(error.to_string()))?;
356        Ok(())
357    }
358
359    pub(super) fn agent_channel_admission(
360        &self,
361        operation: crate::server::connection::ChannelOperation,
362        channel: &str,
363    ) -> Option<Result<(), ChannelAccessError>> {
364        let (name, leaf) = binding::subject(channel)?;
365        if !matches!(leaf, "presence" | "state" | "inbox" | "cancel" | "stream") {
366            return None;
367        }
368        // ACCESS-013 R1: a subscribe to an inbox with no station yet is
369        // decided where the pass is, in `subscribe_agent`: its own holder
370        // places it there, and any other name is refused there by name.
371        if operation == crate::server::connection::ChannelOperation::Subscribe
372            && leaf == "inbox"
373            && self
374                .agents
375                .as_ref()
376                .is_some_and(|agents| agents.registry().record(name).is_err())
377        {
378            return Some(Ok(()));
379        }
380        Some(self.ensure_agent_channel(channel).map_err(|error| {
381            ChannelAccessError::RosterUnavailable {
382                message: error.to_string(),
383            }
384        }))
385    }
386
387    /// Keeps agent station metadata in the topology published by the ordinary hub.
388    ///
389    /// # Errors
390    /// A topology or registry record cannot be encoded.
391    pub fn agent_topology_payload<'a>(
392        &self,
393        subject: &str,
394        payload: &'a [u8],
395    ) -> Result<std::borrow::Cow<'a, [u8]>, crate::console::ConsoleError> {
396        let Some(agents) = &self.agents else {
397            return Ok(std::borrow::Cow::Borrowed(payload));
398        };
399        if subject != crate::console::TOPOLOGY_SUBJECT {
400            return Ok(std::borrow::Cow::Borrowed(payload));
401        }
402        let bytes = crate::console::topology::with_agents(payload, agents, self.console.device())
403            .map_err(|error| crate::console::ConsoleError::Server {
404            subject: subject.into(),
405            source: error.into(),
406        })?;
407        Ok(std::borrow::Cow::Owned(bytes))
408    }
409}
410
411fn validate_inbox(
412    bounds: &crate::agents::config::AgentsConfig,
413    payload: &[u8],
414) -> Result<(), AgentError> {
415    if payload.len() > bounds.max_event_bytes {
416        return Err(AgentError::Bound {
417            bound: "max_event_bytes",
418            limit: bounds.max_event_bytes,
419            count: payload.len(),
420        });
421    }
422    let value: serde_json::Value =
423        serde_json::from_slice(payload).map_err(|_| AgentError::Invalid("inbox JSON"))?;
424    // A prompt is {text}; {command} is a harness command; {config} and {session} read or change
425    // the harness's own options and conversations. Each control holds only text and switches.
426    let control = |value: &serde_json::Value, keys: &[&str]| {
427        value.as_object().is_some_and(|fields| {
428            fields.iter().all(|(key, value)| {
429                keys.contains(&key.as_str()) && (value.is_string() || value.is_boolean())
430            })
431        })
432    };
433    // The sender may be named beside the one message; the exchange overwrites it (CON-022 R3).
434    let accepted = value.as_object().is_some_and(|object| {
435        let named = object.get("from").map(serde_json::Value::is_string);
436        named != Some(false)
437            && object.len() == 1 + usize::from(named.is_some())
438            && (object.get("text").is_some_and(serde_json::Value::is_string)
439                || object
440                    .get("config")
441                    .is_some_and(|c| control(c, &["option", "value"]))
442                || object
443                    .get("command")
444                    .is_some_and(|c| control(c, &["name", "input"]))
445                || object
446                    .get("session")
447                    .is_some_and(|c| control(c, &["list", "new", "open"])))
448    });
449    if !accepted {
450        return Err(AgentError::Invalid(
451            "inbox accepts exactly one of {text}, {command}, {config} or {session}",
452        ));
453    }
454    Ok(())
455}
456
457/// The sender an inbox message names, checked against the session's holder (CON-022 R3).
458///
459/// # Errors
460/// The message is not JSON, or names a sender that is not the holder.
461fn check_sender(payload: &[u8], holder: Option<&str>) -> Result<(), AgentError> {
462    let value: serde_json::Value =
463        serde_json::from_slice(payload).map_err(|_| AgentError::Invalid("inbox JSON"))?;
464    match (
465        value.get("from").and_then(serde_json::Value::as_str),
466        holder,
467    ) {
468        (None, _) => Ok(()),
469        (Some(claimed), Some(holder)) if claimed == holder => Ok(()),
470        _ => Err(AgentError::Invalid(
471            "inbox from names a sender other than the session's holder",
472        )),
473    }
474}
475
476#[cfg(test)]
477#[path = "server_tests.rs"]
478mod tests;
479
480type AgentOwners = (
481    Option<std::sync::Arc<crate::agents::runtime::Agents>>,
482    Option<std::sync::Arc<crate::agents::waiting::durable::ApprovalAgents>>,
483    Option<std::sync::Arc<crate::agents::waiting::AgentDelivery>>,
484);
485fn draft_error(error: crate::auth_lys::drafts::DraftError) -> ServerError {
486    error.into()
487}
488impl From<crate::auth_lys::drafts::DraftError> for ServerError {
489    fn from(error: crate::auth_lys::drafts::DraftError) -> Self {
490        Self::ConfigValidation {
491            message: error.to_string(),
492        }
493    }
494}