Skip to main content

laser_sdk/agent/
builder.rs

1use crate::agent::consumer::{
2    AgentHandler, AgentMiddleware, ConcurrencyPolicy, DeadLetterSink, Deduplicator,
3    ReliableConsumer, RetryPolicy,
4};
5use crate::agent::router::InboxRoute;
6use crate::error::LaserError;
7use crate::laser::Laser;
8use crate::provenance::AgentTopic;
9use crate::types::{AgentId, ConsumerGroupName};
10use laser_wire::agent::{AgentCard, CapabilityDescriptor};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::oneshot;
14use tokio::task::JoinHandle;
15use tracing::warn;
16
17/// One logical agent defaults to one Iggy consumer group.
18///
19/// `spawn` derives the group name from `id` unless `consumer_group` is set. Horizontal
20/// scaling (multiple replicas of the same agent) means multiple processes
21/// joining that group, and Iggy load-balances messages across them.
22///
23/// **Do NOT put two different agent ids in the same group.** The
24/// `ReliableConsumer` skips messages whose `agdx.to` does not
25/// match its own `id`, which is the right behavior when a publisher
26/// addresses a specific agent on a shared topic. If two agents shared
27/// a group, Iggy would still deliver each message to exactly one member,
28/// and if it landed on the wrong one the filter would drop it and the
29/// intended agent would never see it. Each `Agent::builder().id(...).spawn(..)`
30/// call is its own consumer group. Do not bypass that by reusing the
31/// consumer group name across distinct agent ids.
32#[derive(bon::Builder)]
33pub struct Agent<H> {
34    /// The agent's stable logical identity.
35    pub id: AgentId,
36    /// Deployment group for load balancing replicas. Defaults to the agent id's
37    /// spelling but remains a distinct type and may be overridden explicitly.
38    pub consumer_group: Option<ConsumerGroupName>,
39    /// The topic this agent consumes.
40    pub listen_on: AgentTopic<'static>,
41    /// The handler invoked for each message.
42    pub handler: H,
43    /// Default reply topic for `AgentCtx::respond` (none = `respond` errors).
44    pub respond_on: Option<AgentTopic<'static>>,
45    /// Default route from a resolved target agent to the topic its work is sent
46    /// on, used by [`AgentCtx::fan_out`](crate::agent::AgentCtx::fan_out) and the
47    /// directed-send helpers. Sticks as this agent's routing convention. Defaults
48    /// to [`InboxRoute::Advertised`] (resolve each target to its live-presence
49    /// inbox). Set [`InboxRoute::Fixed`] for a workflow topic the caller owns.
50    #[builder(default)]
51    pub inbox_route: InboxRoute,
52    /// Override the consumer poll interval (default: the runtime's reactive 10ms).
53    pub poll_interval: Option<Duration>,
54    /// How long [`AgentHandle::shutdown`] waits for the in-flight message to
55    /// finish draining before the consumer is dropped. [`AgentHandle::abort`] is
56    /// the unconditional hard stop. Defaults to the consumer's 30s grace.
57    pub shutdown_grace: Option<Duration>,
58    /// How message handling is scheduled across the partitions this agent is
59    /// assigned. Defaults to [`ConcurrencyPolicy::Serial`]. Set
60    /// [`ConcurrencyPolicy::SerialPerPartition`] so a slow or retrying message on
61    /// one partition does not stall the others.
62    pub concurrency: Option<ConcurrencyPolicy>,
63    /// Override the total records buffered across partition lanes.
64    pub max_queued_records: Option<usize>,
65    /// Override the total payload and header bytes buffered across partition lanes.
66    pub max_queued_bytes: Option<usize>,
67    #[builder(default)]
68    pub warm_dedup: bool,
69    /// Cross-cutting hooks wrapped around each handler dispatch, in order, for
70    /// auth, metrics, and tracing without touching the handler.
71    #[builder(default)]
72    pub middleware: Vec<Arc<dyn AgentMiddleware>>,
73    /// Notified on every dead-letter with the result of publishing it, so a lost
74    /// poison message is an observable event rather than only a log line.
75    pub on_dead_letter: Option<Arc<dyn DeadLetterSink>>,
76    /// The command operations the handler serves. Unset serves every
77    /// operation. A command for another operation is skipped.
78    pub operations: Option<Vec<String>>,
79    /// The session configuration the agent's runtime applies: the lens
80    /// [`AgentCtx::session`](crate::agent::AgentCtx::session) opens and
81    /// [`SessionConfig::fail_on_dead_letter`](crate::agent::SessionConfig::fail_on_dead_letter).
82    /// Defaults to [`SessionConfig::default`](crate::agent::SessionConfig::default).
83    pub sessions: Option<crate::agent::SessionConfig>,
84    /// Override the dedup window size (recent idempotency keys kept in memory).
85    /// Defaults to the [`ReliableConsumer`] default when unset.
86    pub dedup_window: Option<usize>,
87    /// Override the handler retry policy (attempts + backoff for a retryable
88    /// handler error). Defaults to the [`RetryPolicy`] default when unset.
89    pub retry: Option<RetryPolicy>,
90    /// Must-understand AGDX feature bits implemented by this agent. A message
91    /// requiring any other bit is dead-lettered before the handler runs.
92    #[builder(default = laser_wire::agent::features::NONE)]
93    pub understood_features: u64,
94    /// Override the dedup backend (e.g. a durable `StateStore`-backed one). Defaults
95    /// to the in-memory sliding window.
96    pub deduplicator: Option<Box<dyn Deduplicator>>,
97    /// The signature verifier for control and effect topics: when set, every
98    /// message's envelope signature is verified against this registry before
99    /// dispatch, and an unsigned or unverified record is dead-lettered. This is
100    /// the enforcement gate for authorship and authorization, so it is reachable
101    /// from the primary builder rather than only the lower-level consumer.
102    #[cfg(feature = "sign")]
103    pub verifier: Option<std::sync::Arc<crate::sign::KeyRegistry>>,
104    /// This agent's signing identity: when set, `AgentCtx::respond` answers a
105    /// correlated command with a signed AGDX response instead of a plain reply,
106    /// so a caller holding a verifier (and the contract path's signer binding)
107    /// can accept this agent's terminals and no one else's.
108    #[cfg(feature = "sign")]
109    pub signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
110    /// What this agent serves. When non-empty, on spawn the agent publishes a
111    /// capability card to the registry (so capability routing can discover it) and
112    /// advertises its inbox (its `listen_on` topic) as live presence (so a fan-out
113    /// resolves where to send it work). Empty means a private agent that does not
114    /// advertise, the prior behavior.
115    #[builder(default)]
116    pub capabilities: Vec<CapabilityDescriptor>,
117    /// Emit a `Working` status the moment an AGDX command is picked up, before the
118    /// handler runs, so a [`Laser::contract`](crate::laser::Laser::contract) caller
119    /// can tell the command was consumed. Off by default. Enable it on agents that
120    /// take contracted work, with a `respond_on` set.
121    #[builder(default)]
122    pub ack_on_pickup: bool,
123    /// Run `consolidator` every `consolidate_every` off the handler loop (its
124    /// own task, stopped with the agent). Both must be set for the tick to
125    /// exist. Absent means no background consolidation, ever. Each pass runs
126    /// over this agent's own memory: the scope carries the agent id and
127    /// nothing else. A zero period fails the agent with
128    /// [`LaserError::Config`] instead of starting.
129    pub consolidate_every: Option<Duration>,
130    /// The consolidation pass the periodic tick runs (see
131    /// [`consolidate_every`](Self::consolidate_every)).
132    pub consolidator: Option<crate::memory::SharedConsolidator>,
133    /// The pre-effect policy hook applied to everything this agent's handler
134    /// publishes (`ctx.send`/`respond`/`request`/`fan_out`, AGDX verbs, memory
135    /// writes), with the [`GovernorMode`](crate::govern::GovernorMode) it runs
136    /// under. Spawn re-scopes the agent's `Laser` with it, so a governor set on
137    /// the connection is replaced for this agent. See
138    /// [`Laser::with_governor`](crate::laser::Laser::with_governor).
139    pub governor: Option<(
140        Arc<dyn crate::govern::ActionGovernor>,
141        crate::govern::GovernorMode,
142    )>,
143    /// Process-local evidence-chain retention for this agent's governor. A
144    /// restart or eviction begins a new local chain for the conversation.
145    pub governor_retention: Option<crate::govern::GovernorRetention>,
146}
147
148impl<H> Agent<H>
149where
150    H: AgentHandler + Sync + Send + 'static,
151{
152    /// Spawn the agent on `laser` as its own consumer group, returning a handle to stop it.
153    #[tracing::instrument(target = "laser", level = "info", skip_all, fields(agent = %self.id, operation = "spawn"))]
154    pub fn spawn(self, laser: Laser) -> AgentHandle {
155        let laser = match self.governor {
156            Some((governor, mode)) => match self.governor_retention {
157                Some(retention) => laser.with_governor_retention(governor, mode, retention),
158                None => laser.with_governor(governor, mode),
159            },
160            None => laser,
161        };
162        let id = self.id.clone();
163        let listen_on = self.listen_on.clone();
164        let capabilities = self.capabilities;
165        let group = self
166            .consumer_group
167            .unwrap_or_else(|| ConsumerGroupName::for_agent(&id));
168        // The agent's session configuration declares its stream's layout on
169        // this connection, so the agent reads and routes by it.
170        if let Some(config) = &self.sessions {
171            let _ = laser.sessions_with(config.clone());
172        }
173        // A declared agent of a per-agent topic layout reads its own topic in
174        // place of the lane.
175        let topic = Some(self.listen_on.topic_string())
176            .filter(|topic| topic != laser_wire::topics::AGENT_SESSIONS)
177            .or_else(|| laser.declared_topic(&id.wire_id()))
178            .unwrap_or_else(|| self.listen_on.topic_string());
179        let handler = self.handler;
180        let respond_on = self.respond_on;
181        let inbox_route = self.inbox_route;
182        let ack_on_pickup = self.ack_on_pickup;
183        let poll_interval = self.poll_interval;
184        let warm_dedup = self.warm_dedup;
185        let deduplicator = self.deduplicator;
186        let shutdown_grace = self.shutdown_grace;
187        let concurrency = self.concurrency;
188        let max_queued_records = self.max_queued_records;
189        let max_queued_bytes = self.max_queued_bytes;
190        let dedup_window = self.dedup_window;
191        let retry = self.retry;
192        let understood_features = self.understood_features;
193        let middleware = self.middleware;
194        let on_dead_letter = self.on_dead_letter;
195        let operations = self.operations;
196        let sessions = self.sessions;
197        #[cfg(feature = "sign")]
198        let verifier = self.verifier;
199        #[cfg(feature = "sign")]
200        let signing_key = self.signing_key;
201        let (shutdown, shutdown_rx) = oneshot::channel();
202        let (ready_tx, ready_rx) = oneshot::channel();
203        // A zero period would panic inside the interval timer, so it fails the
204        // agent instead of starting the tick.
205        let zero_period = self.consolidate_every.is_some_and(|every| every.is_zero());
206        // No background magic unless both knobs are set.
207        let consolidation = match (self.consolidate_every, self.consolidator) {
208            (Some(every), Some(consolidator)) if !zero_period => {
209                Some(spawn_consolidation(&id, every, consolidator))
210            }
211            _ => None,
212        };
213        let task = tokio::spawn(async move {
214            if zero_period {
215                return Err(LaserError::Config(
216                    "consolidate_every must be greater than zero",
217                ));
218            }
219            if !capabilities.is_empty() {
220                advertise(&laser, &id, &listen_on, capabilities).await?;
221            }
222            let consumer = ReliableConsumer::builder()
223                .group(group)
224                .agent(id)
225                .topic(topic)
226                .maybe_respond_on(respond_on)
227                .inbox_route(inbox_route)
228                .ack_on_pickup(ack_on_pickup)
229                .maybe_poll_interval(poll_interval)
230                .maybe_shutdown_grace(shutdown_grace)
231                .maybe_concurrency(concurrency)
232                .maybe_max_queued_records(max_queued_records)
233                .maybe_max_queued_bytes(max_queued_bytes)
234                .warm_dedup(warm_dedup)
235                .maybe_deduplicator(deduplicator)
236                .maybe_dedup_window(dedup_window)
237                .maybe_retry(retry)
238                .understood_features(understood_features)
239                .middleware(middleware)
240                .maybe_on_dead_letter(on_dead_letter)
241                .maybe_operations(operations)
242                .maybe_sessions(sessions);
243            #[cfg(feature = "sign")]
244            let consumer = consumer
245                .maybe_verifier(verifier)
246                .maybe_signing_key(signing_key);
247            consumer
248                .build()
249                .run(&laser, handler, ready_tx, shutdown_rx)
250                .await
251        });
252        AgentHandle {
253            shutdown: Some(shutdown),
254            task: Some(task),
255            ready: Some(ready_rx),
256            consolidation,
257        }
258    }
259}
260
261/// Advertise a capability-bearing agent before it starts consuming: publish a
262/// non-expiring capability card to the registry (durable discovery), and, where
263/// the server serves the presence command, advertise its inbox (its `listen_on`
264/// topic) as live presence (so capability fan-out resolves where to send work).
265/// Both are best-effort: a failure logs and the agent still runs, since a missing
266/// advertisement degrades discovery but must not stop the agent.
267pub(crate) async fn advertise(
268    laser: &Laser,
269    id: &AgentId,
270    listen_on: &AgentTopic<'static>,
271    capabilities: Vec<CapabilityDescriptor>,
272) -> Result<(), LaserError> {
273    let card = AgentCard {
274        name: None,
275        version: None,
276        capabilities,
277        ttl_micros: None,
278    };
279    if let Err(error) = laser.publish_card(id.clone(), &card).await {
280        warn!(%error, agent = %id, "failed to publish the capability card");
281    }
282    #[cfg(feature = "query")]
283    {
284        let presence = laser_wire::agent::AgentPresence::new(id.wire_id())
285            .with_inbox(listen_on.topic_string());
286        if let Err(error) = laser.advertise_presence(&presence).await {
287            if matches!(error, LaserError::PresenceConflict { .. }) {
288                return Err(error);
289            }
290            // Expected against a streaming server without the presence command,
291            // where routing falls back to a fixed inbox route. Not an error.
292            tracing::debug!(%error, agent = %id, "inbox presence not advertised");
293        }
294    }
295    #[cfg(not(feature = "query"))]
296    let _ = listen_on;
297    Ok(())
298}
299
300/// Owns a spawned agent. Dropping it signals a graceful stop and stops consolidation. Call `shutdown` or `join` to observe the consumer result.
301#[must_use = "keep the handle alive until the agent finishes or is shut down"]
302pub struct AgentHandle {
303    shutdown: Option<oneshot::Sender<()>>,
304    task: Option<JoinHandle<Result<(), LaserError>>>,
305    ready: Option<oneshot::Receiver<()>>,
306    // The background consolidation tick, aborted whenever the agent stops.
307    consolidation: Option<JoinHandle<()>>,
308}
309
310impl AgentHandle {
311    /// Wait until the agent has joined its group and is polling, so a publish after
312    /// this is delivered rather than racing the join. Idempotent.
313    pub async fn ready(&mut self) -> Result<(), LaserError> {
314        if let Some(ready) = self.ready.take()
315            && ready.await.is_err()
316        {
317            // The runtime stopped before it was ready: surface why.
318            if let Some(task) = self.task.take() {
319                match task.await {
320                    Ok(Err(error)) => return Err(error),
321                    Err(error) => return Err(LaserError::HandlerConfig(error.to_string())),
322                    Ok(Ok(())) => {}
323                }
324            }
325            return Err(LaserError::HandlerConfig(
326                "agent stopped before it became ready".to_owned(),
327            ));
328        }
329        Ok(())
330    }
331
332    /// Signal the agent to stop, wait for it, and surface any consumer error.
333    pub async fn shutdown(mut self) -> Result<(), LaserError> {
334        if let Some(shutdown) = self.shutdown.take() {
335            let _ = shutdown.send(());
336        }
337        if let Some(consolidation) = &self.consolidation {
338            consolidation.abort();
339        }
340        Self::join_task(self.task.take().expect("agent task is retained")).await
341    }
342
343    /// Wait for the agent to finish (it runs until its consumer ends or errors).
344    pub async fn join(mut self) -> Result<(), LaserError> {
345        let result = Self::join_task(self.task.take().expect("agent task is retained")).await;
346        if let Some(consolidation) = &self.consolidation {
347            consolidation.abort();
348        }
349        result
350    }
351
352    /// Abort the agent's task immediately, without waiting.
353    pub fn abort(&self) {
354        if let Some(task) = &self.task {
355            task.abort();
356        }
357        if let Some(consolidation) = &self.consolidation {
358            consolidation.abort();
359        }
360    }
361
362    async fn join_task(task: JoinHandle<Result<(), LaserError>>) -> Result<(), LaserError> {
363        match task.await {
364            Ok(result) => result,
365            Err(join) => Err(LaserError::HandlerConfig(join.to_string())),
366        }
367    }
368}
369
370impl Drop for AgentHandle {
371    fn drop(&mut self) {
372        if let Some(consolidation) = &self.consolidation {
373            consolidation.abort();
374        }
375        if let Some(shutdown) = self.shutdown.take() {
376            let _ = shutdown.send(());
377        }
378    }
379}
380
381// The consolidation tick, off the handler loop: best-effort, logged, aborted
382// with the agent. A pass covers the agent's own memory, never every
383// conversation in the namespace.
384fn spawn_consolidation(
385    agent: &AgentId,
386    every: Duration,
387    consolidator: crate::memory::SharedConsolidator,
388) -> JoinHandle<()> {
389    let scope = crate::memory::MemoryScope::builder()
390        .agent(agent.clone())
391        .build();
392    tokio::spawn(async move {
393        let mut tick = tokio::time::interval(every);
394        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
395        loop {
396            tick.tick().await;
397            if let Err(error) =
398                crate::memory::Consolidator::consolidate(&consolidator, &scope).await
399            {
400                warn!(%error, "background consolidation pass failed");
401            }
402        }
403    })
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    struct SignalDrop(Option<oneshot::Sender<()>>);
411
412    impl Drop for SignalDrop {
413        fn drop(&mut self) {
414            if let Some(signal) = self.0.take() {
415                let _ = signal.send(());
416            }
417        }
418    }
419
420    struct RecordingConsolidator(tokio::sync::mpsc::UnboundedSender<crate::memory::MemoryScope>);
421
422    impl crate::memory::Consolidator for RecordingConsolidator {
423        async fn consolidate(
424            &self,
425            scope: &crate::memory::MemoryScope,
426        ) -> Result<crate::memory::ConsolidationReport, LaserError> {
427            let _ = self.0.send(scope.clone());
428            Ok(crate::memory::ConsolidationReport::default())
429        }
430    }
431
432    #[tokio::test]
433    async fn given_an_agent_consolidation_tick_when_it_fires_then_should_scope_the_pass_to_the_agent()
434     {
435        let (passes, mut scopes) = tokio::sync::mpsc::unbounded_channel();
436        let agent: AgentId = "planner".parse().expect("valid agent id");
437        let tick = spawn_consolidation(
438            &agent,
439            Duration::from_millis(5),
440            crate::memory::SharedConsolidator::new(RecordingConsolidator(passes)),
441        );
442        let scope = tokio::time::timeout(Duration::from_secs(2), scopes.recv())
443            .await
444            .expect("a pass ran")
445            .expect("the tick is alive");
446        tick.abort();
447        assert_eq!(scope.agent, Some(agent));
448        assert!(scope.conversation.is_none());
449        assert!(scope.user.is_none());
450        assert!(scope.app.is_none());
451        assert!(scope.stream.is_none());
452    }
453
454    struct IgnoreHandler;
455
456    impl crate::agent::AgentHandler for IgnoreHandler {
457        async fn handle(
458            &self,
459            _message: &crate::agent::AgentMessage,
460            _ctx: &crate::agent::AgentCtx<'_>,
461        ) -> Result<(), LaserError> {
462            Ok(())
463        }
464    }
465
466    #[tokio::test]
467    async fn given_a_zero_consolidation_period_when_spawned_then_should_fail_as_config() {
468        let (passes, _scopes) = tokio::sync::mpsc::unbounded_channel();
469        let laser = Laser::from_client(iggy::prelude::IggyClient::default())
470            .with_default_stream("zero-tick");
471        let mut handle = Agent::builder()
472            .id("planner".parse().expect("valid agent id"))
473            .listen_on(AgentTopic::Sessions)
474            .handler(IgnoreHandler)
475            .consolidate_every(Duration::ZERO)
476            .consolidator(crate::memory::SharedConsolidator::new(
477                RecordingConsolidator(passes),
478            ))
479            .build()
480            .spawn(laser);
481        assert!(matches!(handle.ready().await, Err(LaserError::Config(_))));
482    }
483
484    #[tokio::test]
485    async fn given_a_running_agent_when_its_handle_is_dropped_then_should_stop_the_worker_and_consolidation()
486     {
487        let (shutdown, stop) = oneshot::channel();
488        let (worker_done, worker_stopped) = oneshot::channel();
489        let task = tokio::spawn(async move {
490            let _ = stop.await;
491            let _ = worker_done.send(());
492            Ok(())
493        });
494        let (started, running) = oneshot::channel();
495        let (consolidation_done, consolidation_stopped) = oneshot::channel();
496        let consolidation = tokio::spawn(async move {
497            let _finished = SignalDrop(Some(consolidation_done));
498            let _ = started.send(());
499            std::future::pending::<()>().await;
500        });
501        let handle = AgentHandle {
502            shutdown: Some(shutdown),
503            task: Some(task),
504            ready: None,
505            consolidation: Some(consolidation),
506        };
507        running.await.expect("consolidation started");
508        drop(handle);
509        tokio::time::timeout(Duration::from_secs(2), worker_stopped)
510            .await
511            .expect("worker stopped")
512            .expect("worker reported its stop");
513        tokio::time::timeout(Duration::from_secs(2), consolidation_stopped)
514            .await
515            .expect("consolidation stopped")
516            .expect("consolidation reported its stop");
517    }
518}