Skip to main content

laser_sdk/agent/
ctx.rs

1use crate::agent::clock::{Clock, SystemClock};
2use crate::agent::consumer::AgentMessage;
3use crate::agent::router::{CapabilitySelector, InboxRoute, Router};
4use crate::error::LaserError;
5use crate::laser::Laser;
6use crate::provenance::{AgentTopic, Provenance};
7use crate::types::AgentId;
8use std::time::Duration;
9
10/// Handed to an `AgentHandler::handle` so it can reply, send, request, or fan out
11/// without holding `Laser` itself. Causality (conversation_id, causal_parent,
12/// root) is wired automatically off the message being handled.
13pub struct AgentCtx<'a> {
14    laser: &'a Laser,
15    message: &'a AgentMessage,
16    agent: Option<AgentId>,
17    respond_on: Option<AgentTopic<'static>>,
18    inbox_route: InboxRoute,
19    request_at: Option<laser_wire::agent::LogPosition>,
20    // The runtime's session factory and control state, when a reliable
21    // consumer handed this context to its handler.
22    sessions: Option<crate::agent::Sessions>,
23    control: Option<std::sync::Arc<crate::agent::control::ControlBook>>,
24    #[cfg(feature = "sign")]
25    signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
26}
27
28impl<'a> AgentCtx<'a> {
29    pub(crate) fn new(
30        laser: &'a Laser,
31        message: &'a AgentMessage,
32        agent: Option<AgentId>,
33        respond_on: Option<AgentTopic<'static>>,
34        inbox_route: InboxRoute,
35        #[cfg(feature = "sign")] signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
36    ) -> Self {
37        Self {
38            laser,
39            message,
40            agent,
41            respond_on,
42            inbox_route,
43            request_at: None,
44            sessions: None,
45            control: None,
46            #[cfg(feature = "sign")]
47            signing_key,
48        }
49    }
50
51    // The session factory and control state of the runtime handling the
52    // record, so `session` follows its configuration and control requests.
53    pub(crate) fn with_sessions(
54        mut self,
55        sessions: crate::agent::Sessions,
56        control: std::sync::Arc<crate::agent::control::ControlBook>,
57    ) -> Self {
58        self.sessions = Some(sessions);
59        self.control = Some(control);
60        self
61    }
62
63    /// Where the handled record sits on the log, when the runtime knows it.
64    pub fn request_at(&self) -> Option<laser_wire::agent::LogPosition> {
65        self.request_at
66    }
67
68    // Where the handled record sits on the log, stamped on typed replies as
69    // their causal position.
70    pub(crate) fn with_request_at(mut self, at: laser_wire::agent::LogPosition) -> Self {
71        self.request_at = Some(at);
72        self
73    }
74
75    /// The `Laser` handle, for operations the ctx helpers do not cover (`kv`, `query`, ...).
76    pub fn laser(&self) -> &Laser {
77        self.laser
78    }
79
80    /// The message currently being handled.
81    pub fn message(&self) -> &AgentMessage {
82        self.message
83    }
84
85    /// The session of the handled record, written as this agent and acting on
86    /// the handled record: graph writes take it as source and remembered
87    /// items as origin. A lens only: it holds no lease and starts no
88    /// heartbeat. Its [`pending_control`](crate::agent::Session::pending_control)
89    /// reports the pause and cancel requests the runtime observed.
90    pub fn session(&self) -> crate::agent::Session {
91        let conversation = self.message.provenance.conversation_id;
92        let mut session = match &self.sessions {
93            Some(sessions) => sessions.open(conversation),
94            None => self.laser.sessions().open(conversation),
95        };
96        session.control.clone_from(&self.control);
97        if let Some(agent) = &self.agent {
98            session = session.as_agent(agent.wire_id());
99        }
100        if let Some(at) = self.request_at {
101            session = session.acting_on(laser_wire::graph::SourceRef::Message {
102                stream: at.stream_id,
103                topic: at.topic_id,
104                partition: at.partition_id,
105                offset: at.offset,
106                generation: None,
107                conversation: Some(self.message.provenance.conversation_id.to_string()),
108            });
109        }
110        session
111    }
112
113    /// Reply on the agent's configured `respond_on` topic, chaining causality
114    /// (causal_parent = this message) and routing back to its sender. Errors with
115    /// `NoRespondTopic` if the agent was built without `respond_on`.
116    ///
117    /// An agent built with a signing key answers a correlated command with a
118    /// signed AGDX response instead, so a verifying caller (and the contract
119    /// path's signer binding) accepts this agent's terminal and no one else's.
120    pub async fn respond(&self, payload: impl Into<Vec<u8>>) -> Result<(), LaserError> {
121        let topic = self.respond_on.clone().ok_or(LaserError::NoRespondTopic)?;
122        let payload = payload.into();
123        // A typed request gets a typed response: correlated, addressed to the
124        // requester, and caused by the request's log position. A plain request
125        // gets a plain reply matched by its string correlation.
126        if let Some(envelope) = &self.message.envelope
127            && let Some(correlation) = envelope.correlation
128        {
129            let source = self.agent.as_ref().ok_or_else(|| {
130                LaserError::HandlerConfig("a responding agent must have an id".to_owned())
131            })?;
132            let producer = self
133                .laser
134                .agdx(topic, source.wire_id(), envelope.conversation);
135            let mut send = producer
136                .respond(correlation, payload)
137                .with_target(envelope.source.clone())
138                .with_ancestry(envelope.parent, envelope.root);
139            if let Some(record) = envelope.record {
140                send = send.with_cause(record, self.request_at);
141            }
142            #[cfg(feature = "sign")]
143            if let Some(key) = &self.signing_key {
144                send = send.signed_by(key);
145            }
146            return send.send().await.map(|_| ());
147        }
148        let mut provenance = self.reply_provenance();
149        if let Some(source) = &self.message.provenance.agent {
150            Router::to(source.clone()).apply(&mut provenance);
151        }
152        self.laser.send_agent(topic, payload, &provenance).await
153    }
154
155    /// A reply provenance for `topic`, chained off this message. The caller sets
156    /// routing and usage as needed. Useful when replying somewhere other than `respond_on`.
157    pub async fn reply_on(
158        &self,
159        topic: AgentTopic<'_>,
160        payload: impl Into<Vec<u8>>,
161    ) -> Result<(), LaserError> {
162        let mut provenance = self.reply_provenance();
163        if let Some(source) = &self.message.provenance.agent {
164            Router::to(source.clone()).apply(&mut provenance);
165        }
166        self.laser.send_agent(topic, payload, &provenance).await
167    }
168
169    /// Send `payload` to `topic` with an explicit `provenance` (no automatic causality).
170    pub async fn send(
171        &self,
172        topic: AgentTopic<'_>,
173        payload: impl Into<Vec<u8>>,
174        provenance: &Provenance,
175    ) -> Result<(), LaserError> {
176        self.laser.send_agent(topic, payload, provenance).await
177    }
178
179    /// Send a request and await its correlated reply (see `Laser::request`).
180    pub async fn request(
181        &self,
182        request_topic: AgentTopic<'_>,
183        reply_topic: AgentTopic<'_>,
184        payload: impl Into<Vec<u8>>,
185        provenance: &Provenance,
186        timeout: Duration,
187    ) -> Result<AgentMessage, LaserError> {
188        self.laser
189            .request(request_topic, reply_topic, payload, provenance, timeout)
190            .await
191    }
192
193    /// Resolve the human-in-the-loop interrupt being handled: publish an AGDX
194    /// `response` on `reply_topic` carrying the handled command's interrupt
195    /// correlation, so the paused [`Agdx::request_input`](crate::agent::Agdx::request_input)
196    /// caller resumes with `response`. The pairing is the correlation, so the
197    /// reply reaches the right waiter even when several share `reply_topic`.
198    /// An agent built with a signing key signs the response, so a verifying
199    /// interrupt caller accepts this agent's decision and no one else's.
200    /// Errors if the handled message is not an AGDX envelope carrying a
201    /// correlation, or the agent was built without an id.
202    pub async fn respond_input(
203        &self,
204        reply_topic: AgentTopic<'static>,
205        response: impl Into<Vec<u8>>,
206    ) -> Result<(), LaserError> {
207        let envelope = self.message.envelope.as_ref().ok_or_else(|| {
208            LaserError::HandlerConfig(
209                "respond_input: the handled message is not an AGDX envelope".to_owned(),
210            )
211        })?;
212        let correlation = envelope.correlation.ok_or_else(|| {
213            LaserError::HandlerConfig(
214                "respond_input: the interrupt carries no correlation".to_owned(),
215            )
216        })?;
217        let source = self
218            .agent
219            .as_ref()
220            .ok_or_else(|| {
221                LaserError::HandlerConfig("respond_input: the agent has no id".to_owned())
222            })?
223            .wire_id();
224        let producer = self.laser.agdx(reply_topic, source, envelope.conversation);
225        let mut send = producer
226            .respond(correlation, response.into())
227            .with_target(envelope.source.clone())
228            .with_ancestry(envelope.parent, envelope.root);
229        if let Some(record) = envelope.record {
230            send = send.with_cause(record, self.request_at);
231        }
232        #[cfg(feature = "sign")]
233        let send = match &self.signing_key {
234            Some(key) => send.signed_by(key),
235            None => send,
236        };
237        send.send().await?;
238        Ok(())
239    }
240
241    /// Pause this handler on a human decision: publish `prompt` as an interrupt on
242    /// the human-input topic and await the approver's correlated reply on
243    /// `reply_topic`, up to `timeout`, chained to the handled conversation. Returns
244    /// the decision body on approval, or [`LaserError::Rejected`] when the approver
245    /// rejects (the approver answers with [`respond_input`](Self::respond_input)).
246    /// A convenience over [`Agdx::request_input`](crate::agent::Agdx::request_input),
247    /// so it adds nothing to the wire. Errors if the agent was built without an id.
248    pub async fn approval_gate(
249        &self,
250        reply_topic: AgentTopic<'_>,
251        prompt: impl Into<Vec<u8>>,
252        timeout: Duration,
253    ) -> Result<Vec<u8>, LaserError> {
254        let source = self
255            .agent
256            .as_ref()
257            .ok_or_else(|| {
258                LaserError::HandlerConfig("approval_gate: the agent has no id".to_owned())
259            })?
260            .wire_id();
261        self.laser
262            .agdx(
263                AgentTopic::Sessions,
264                source,
265                self.message.provenance.conversation_id.into(),
266            )
267            .request_input(reply_topic, prompt, timeout)
268            .await
269    }
270
271    /// A child conversation of the handled message, linked by parent/root ids.
272    pub fn spawn_subconversation(&self) -> Result<Provenance, LaserError> {
273        let author = self.agent.as_ref().ok_or_else(|| {
274            LaserError::HandlerConfig("spawn_subconversation: the agent has no id".to_owned())
275        })?;
276        Ok(self
277            .laser
278            .spawn_subconversation(&self.message.provenance, author))
279    }
280
281    /// Fan out to every agent advertising `selector`'s skill, gathering replies
282    /// under `policy` within `deadline`. Each branch is a sub-conversation routed
283    /// to one agent's own inbox, resolved through the ctx's
284    /// [`InboxRoute`](crate::agent::InboxRoute) (the per-agent live-presence inbox
285    /// by default, or a fixed topic the caller set at build time), never a shared
286    /// topic name baked into the SDK. Replies are collected on the orchestrator's
287    /// own response topic (`respond_on`), so a deployment that scopes each user
288    /// to its own stream and each workflow to its own topic routes correctly.
289    ///
290    /// A target that resolves no inbox (advertised none, or [`refresh_presence`](crate::agent::AgentRegistry::refresh_presence)
291    /// surfaced none) is a per-branch [`Gather::failures`] entry, never silently
292    /// rerouted. Under [`GatherPolicy::Quorum`] the remaining branches are dropped
293    /// once the quorum lands rather than waited on (signing a cancel to the losing
294    /// branches on the control channel is a follow-on). Errors only on an
295    /// infrastructure failure (no capable agent, no response topic, registry read),
296    /// not on a branch failure.
297    pub async fn fan_out(
298        &self,
299        selector: CapabilitySelector,
300        payload: impl Into<Vec<u8>>,
301        policy: GatherPolicy,
302        deadline: Duration,
303    ) -> Result<Gather, LaserError> {
304        // One buffer, refcount-cloned per branch: the fan-out never copies the body.
305        let payload = payload.into();
306        // The orchestrator's own response topic is where every branch's reply
307        // lands. Required: without it there is nowhere to gather to.
308        let reply_topic = self.respond_on.clone().ok_or(LaserError::NoRespondTopic)?;
309
310        let mut registry = self.laser.agent_registry()?;
311        let now = SystemClock.now_micros();
312        registry.refresh(now).await?;
313        // The advertised-inbox path needs live presence. The fixed path does not.
314        #[cfg(feature = "query")]
315        if matches!(self.inbox_route, InboxRoute::Advertised) || selector.principal.is_some() {
316            registry.refresh_presence().await?;
317        }
318        let targets = Router::AllCapable(selector).resolve_targets(&registry, now)?;
319        let author = self
320            .agent
321            .clone()
322            .ok_or_else(|| LaserError::HandlerConfig("fan_out: the agent has no id".to_owned()))?;
323
324        let mut gather = Gather::default();
325
326        // Resolve each target's inbox while the registry borrow is live, so the
327        // owned topic identifier can move into the spawned branch. A target that
328        // resolves no inbox fails its branch here rather than mis-routing.
329        let mut branches = tokio::task::JoinSet::new();
330        for agent in targets {
331            let advertised = registry.inbox_for(&agent);
332            let inbox = match self.inbox_route.resolve(&agent, advertised) {
333                Ok(inbox) => inbox,
334                Err(error) => {
335                    gather.failures.push((agent, error));
336                    continue;
337                }
338            };
339            let laser = self.laser.clone();
340            let body = payload.clone();
341            let parent = self.message.provenance.clone();
342            let author = author.clone();
343            let reply_topic = reply_topic.clone();
344            branches.spawn(async move {
345                let mut provenance = laser.spawn_subconversation(&parent, &author);
346                provenance.target_agent_id = Some(agent.clone());
347                // A distinct correlation per branch so replies never cross.
348                provenance.correlation_id = Some(ulid::Ulid::generate().to_string());
349                let result = laser
350                    .request(
351                        AgentTopic::Custom(&inbox),
352                        reply_topic,
353                        body,
354                        &provenance,
355                        deadline,
356                    )
357                    .await;
358                (agent, result)
359            });
360        }
361
362        Ok(gather_branches(branches, gather, policy, deadline).await)
363    }
364
365    fn reply_provenance(&self) -> Provenance {
366        let mut provenance = Provenance::builder()
367            .conversation_id(self.message.provenance.conversation_id)
368            .causal_parent(self.message.id)
369            .build();
370        provenance.agent = self.agent.clone();
371        provenance.parent_conversation_id = self.message.provenance.parent_conversation_id;
372        provenance.root_conversation_id = self.message.provenance.root_conversation_id;
373        // Echo the request's correlation back so the caller's request/reply
374        // correlator can identify this reply unambiguously. Without this a reply
375        // with only conversation_id matching would be hijackable when multiple
376        // agents share a reply topic. The request's business idempotency_key is
377        // deliberately NOT echoed: the reply is its own operation. A request
378        // without a correlation gets its own position as one, so the reply
379        // names both its cause and a correlation and every reader classifies
380        // it as a reply, never as new work.
381        provenance.correlation_id = Some(
382            self.message
383                .provenance
384                .correlation_id
385                .clone()
386                .unwrap_or_else(|| self.message.id.to_string()),
387        );
388        provenance
389    }
390}
391
392/// When a [`fan_out`](AgentCtx::fan_out) gather is complete.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub enum GatherPolicy {
395    /// Wait for every branch (each bounded by the deadline).
396    RequireAll,
397    /// Stop once this many branches succeed, dropping the wait on the rest.
398    Quorum(usize),
399    /// Take whatever has landed by the deadline.
400    BestEffort,
401}
402
403/// The outcome of a [`fan_out`](AgentCtx::fan_out): the successful replies and
404/// the failed branches. Failures are surfaced here, never silently dropped, so
405/// a slow or dead agent is visible rather than mistaken for a missing result.
406#[derive(Debug, Default)]
407pub struct Gather {
408    /// The agents that replied, each with its reply. Attributed, so a caller can
409    /// tell which agent produced which result rather than a bag of bodies.
410    pub ok: Vec<(AgentId, AgentMessage)>,
411    /// The agents whose branch failed (no inbox, request error, timeout), each
412    /// with its cause. Surfaced, never silently dropped.
413    pub failures: Vec<(AgentId, LaserError)>,
414}
415
416impl Gather {
417    /// The reply bodies alone, dropping agent attribution, for a caller that only
418    /// wants the results.
419    pub fn replies(&self) -> impl Iterator<Item = &AgentMessage> {
420        self.ok.iter().map(|(_, message)| message)
421    }
422}
423
424/// Drain every fan-out branch into `gather` under `policy`. `RequireAll` waits
425/// for every branch, each already bounded by its own request `deadline`. `Quorum`
426/// aborts the remaining branches the moment enough have succeeded. `BestEffort`
427/// takes whatever has landed when the wall-clock `deadline` elapses, aborting the
428/// stragglers rather than waiting out their per-request deadlines. A branch that
429/// panics surfaces as a `Handler` failure, never a lost result.
430async fn gather_branches(
431    mut branches: tokio::task::JoinSet<(AgentId, Result<AgentMessage, LaserError>)>,
432    mut gather: Gather,
433    policy: GatherPolicy,
434    deadline: Duration,
435) -> Gather {
436    let wall_clock = tokio::time::sleep(deadline);
437    tokio::pin!(wall_clock);
438    loop {
439        tokio::select! {
440            joined = branches.join_next() => {
441                let Some(joined) = joined else { break };
442                match joined {
443                    Ok((agent, Ok(reply))) => gather.ok.push((agent, reply)),
444                    Ok((agent, Err(error))) => gather.failures.push((agent, error)),
445                    // A JoinError is a panicked branch (a bug), not a normal
446                    // outcome: the agent identity is unrecoverable here, so log it
447                    // rather than attribute it to the wrong agent.
448                    Err(join_error) => {
449                        tracing::warn!(%join_error, "fan-out branch panicked");
450                    }
451                }
452                if quorum_satisfied(policy, gather.ok.len()) {
453                    branches.abort_all();
454                    break;
455                }
456            }
457            _ = &mut wall_clock, if matches!(policy, GatherPolicy::BestEffort) => {
458                branches.abort_all();
459                break;
460            }
461        }
462    }
463    gather
464}
465
466/// Whether `policy` is satisfied by `successes` replies so far, so the remaining
467/// branches can be cancelled. Only `Quorum` short-circuits: `RequireAll` and
468/// `BestEffort` drain every branch (the latter bounded by the wall-clock
469/// deadline instead).
470fn quorum_satisfied(policy: GatherPolicy, successes: usize) -> bool {
471    matches!(policy, GatherPolicy::Quorum(needed) if successes >= needed)
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn given_a_child_conversation_when_replying_then_should_keep_parent_and_root() {
480        let laser = Laser::from_client(iggy::prelude::IggyClient::default());
481        let root = crate::types::ConversationId::new();
482        let parent = crate::types::ConversationId::new();
483        let child = crate::types::ConversationId::new();
484        let provenance = Provenance::builder()
485            .conversation_id(child)
486            .parent_conversation_id(parent)
487            .root_conversation_id(root)
488            .build();
489        let message = crate::testing::agent_message(b"request".to_vec(), provenance);
490        let ctx = AgentCtx::new(
491            &laser,
492            &message,
493            Some("worker".parse().expect("agent id parses")),
494            None,
495            InboxRoute::default(),
496            #[cfg(feature = "sign")]
497            None,
498        );
499
500        let reply = ctx.reply_provenance();
501        assert_eq!(reply.parent_conversation_id, Some(parent));
502        assert_eq!(reply.root_conversation_id, Some(root));
503    }
504
505    #[test]
506    fn given_require_all_when_checking_quorum_then_should_never_short_circuit() {
507        // RequireAll drains every branch, so no count ever satisfies it early.
508        assert!(!quorum_satisfied(GatherPolicy::RequireAll, 0));
509        assert!(!quorum_satisfied(GatherPolicy::RequireAll, 1));
510        assert!(!quorum_satisfied(GatherPolicy::RequireAll, 1000));
511    }
512
513    #[test]
514    fn given_best_effort_when_checking_quorum_then_should_never_short_circuit() {
515        // BestEffort is cut by the wall-clock deadline, not by a success count.
516        assert!(!quorum_satisfied(GatherPolicy::BestEffort, 0));
517        assert!(!quorum_satisfied(GatherPolicy::BestEffort, 1));
518        assert!(!quorum_satisfied(GatherPolicy::BestEffort, 1000));
519    }
520
521    #[test]
522    fn given_a_quorum_when_checking_then_should_short_circuit_at_or_above_the_threshold() {
523        // Below the threshold keeps waiting, at or above it short-circuits.
524        assert!(!quorum_satisfied(GatherPolicy::Quorum(2), 0));
525        assert!(!quorum_satisfied(GatherPolicy::Quorum(2), 1));
526        assert!(quorum_satisfied(GatherPolicy::Quorum(2), 2));
527        assert!(quorum_satisfied(GatherPolicy::Quorum(2), 3));
528    }
529
530    #[test]
531    fn given_a_zero_quorum_when_checking_then_should_be_satisfied_immediately() {
532        // A degenerate Quorum(0) is satisfied before any branch replies, so the
533        // fan-out aborts every branch and gathers nothing. The edge is defined,
534        // not a panic.
535        assert!(quorum_satisfied(GatherPolicy::Quorum(0), 0));
536    }
537
538    #[test]
539    fn given_a_quorum_larger_than_the_target_set_when_never_reached_then_should_not_short_circuit()
540    {
541        // Quorum(5) against three responders never trips, so the loop drains all
542        // three and the caller sees fewer successes than asked, surfaced as a
543        // short gather rather than a hang.
544        assert!(!quorum_satisfied(GatherPolicy::Quorum(5), 3));
545    }
546
547    #[test]
548    fn given_branches_when_gathered_under_require_all_then_should_classify_replies_and_failures() {
549        // Drive the real drain over a set of finished branches: two replies, one
550        // failed request. RequireAll keeps both buckets, nothing is dropped.
551        tokio_test_block(async {
552            let mut branches = tokio::task::JoinSet::new();
553            let agent: AgentId = "worker".parse().expect("valid agent id");
554            branches.spawn(async move { (agent, Err(LaserError::Timeout("reply"))) });
555            let gather = gather_branches(
556                branches,
557                Gather::default(),
558                GatherPolicy::RequireAll,
559                Duration::from_secs(1),
560            )
561            .await;
562            assert_eq!(gather.ok.len(), 0);
563            assert_eq!(gather.failures.len(), 1);
564            assert!(matches!(gather.failures[0].1, LaserError::Timeout(_)));
565        });
566    }
567
568    #[test]
569    fn given_a_best_effort_deadline_when_a_branch_outlives_it_then_should_cut_and_return_what_landed()
570     {
571        // One branch never completes within the wall-clock deadline. BestEffort
572        // aborts it at the deadline and returns the empty (but not hung) gather.
573        tokio_test_block(async {
574            let mut branches = tokio::task::JoinSet::new();
575            let agent: AgentId = "worker".parse().expect("valid agent id");
576            branches.spawn(async move {
577                futures_pending().await;
578                (agent, Ok::<_, LaserError>(unreachable_reply()))
579            });
580            let gather = gather_branches(
581                branches,
582                Gather::default(),
583                GatherPolicy::BestEffort,
584                Duration::from_millis(20),
585            )
586            .await;
587            assert_eq!(gather.ok.len(), 0);
588            assert_eq!(
589                gather.failures.len(),
590                0,
591                "an aborted straggler is dropped, not a failure"
592            );
593        });
594    }
595
596    // A current-thread runtime with the timer enabled, so the BestEffort
597    // wall-clock cut fires against a real (short) deadline.
598    fn tokio_test_block<F: std::future::Future>(f: F) -> F::Output {
599        tokio::runtime::Builder::new_current_thread()
600            .enable_time()
601            .build()
602            .expect("test runtime builds")
603            .block_on(f)
604    }
605
606    async fn futures_pending() {
607        std::future::pending::<()>().await
608    }
609
610    fn unreachable_reply() -> AgentMessage {
611        unreachable!("the pending branch is aborted before it ever yields a reply")
612    }
613}