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#[derive(bon::Builder)]
33pub struct Agent<H> {
34 pub id: AgentId,
36 pub consumer_group: Option<ConsumerGroupName>,
39 pub listen_on: AgentTopic<'static>,
41 pub handler: H,
43 pub respond_on: Option<AgentTopic<'static>>,
45 #[builder(default)]
51 pub inbox_route: InboxRoute,
52 pub poll_interval: Option<Duration>,
54 pub shutdown_grace: Option<Duration>,
58 pub concurrency: Option<ConcurrencyPolicy>,
63 pub max_queued_records: Option<usize>,
65 pub max_queued_bytes: Option<usize>,
67 #[builder(default)]
68 pub warm_dedup: bool,
69 #[builder(default)]
72 pub middleware: Vec<Arc<dyn AgentMiddleware>>,
73 pub on_dead_letter: Option<Arc<dyn DeadLetterSink>>,
76 pub operations: Option<Vec<String>>,
79 pub sessions: Option<crate::agent::SessionConfig>,
84 pub dedup_window: Option<usize>,
87 pub retry: Option<RetryPolicy>,
90 #[builder(default = laser_wire::agent::features::NONE)]
93 pub understood_features: u64,
94 pub deduplicator: Option<Box<dyn Deduplicator>>,
97 #[cfg(feature = "sign")]
103 pub verifier: Option<std::sync::Arc<crate::sign::KeyRegistry>>,
104 #[cfg(feature = "sign")]
109 pub signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
110 #[builder(default)]
116 pub capabilities: Vec<CapabilityDescriptor>,
117 #[builder(default)]
122 pub ack_on_pickup: bool,
123 pub consolidate_every: Option<Duration>,
130 pub consolidator: Option<crate::memory::SharedConsolidator>,
133 pub governor: Option<(
140 Arc<dyn crate::govern::ActionGovernor>,
141 crate::govern::GovernorMode,
142 )>,
143 pub governor_retention: Option<crate::govern::GovernorRetention>,
146}
147
148impl<H> Agent<H>
149where
150 H: AgentHandler + Sync + Send + 'static,
151{
152 #[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 if let Some(config) = &self.sessions {
171 let _ = laser.sessions_with(config.clone());
172 }
173 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 let zero_period = self.consolidate_every.is_some_and(|every| every.is_zero());
206 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
261pub(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 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#[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 consolidation: Option<JoinHandle<()>>,
308}
309
310impl AgentHandle {
311 pub async fn ready(&mut self) -> Result<(), LaserError> {
314 if let Some(ready) = self.ready.take()
315 && ready.await.is_err()
316 {
317 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 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 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 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
381fn 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}