Skip to main content

laser_sdk/agent/
laser.rs

1use crate::agent::consumer::AgentMessage;
2use crate::agent::{ChunkAssembler, StreamEvent};
3use crate::context::ContextAssembler;
4use crate::error::LaserError;
5use crate::govern::{ActionCounters, ActionKind, GovernedAction};
6use crate::laser::{Laser, ensure_stream, ensure_topic, ensure_topic_retained};
7use crate::provenance::{AgentTopic, Provenance};
8use crate::types::{AgentId, ConsumerGroupName, ConversationId, MessageId};
9use iggy::prelude::*;
10use laser_wire::agent::{
11    AgentDeadLetter, AgentEnvelope, AgentKind, ChannelId, CorrelationId, LogPosition,
12    OPERATION_TASK, TaskState,
13};
14use laser_wire::dispatch::{Dispatch, HandledOperations, classify};
15use std::collections::BTreeMap;
16use std::str::FromStr;
17use std::time::Duration;
18use tokio::time::{Instant, sleep};
19use tracing::info;
20
21// The satellites bootstrap creates beside `agent.sessions`. `agent.control`
22// is never among them: provisioning creates it with an operator send grant.
23// `agent.registry` is created by the first card or registry fact, as before.
24const SATELLITE_TOPICS: [AgentTopic<'static>; 5] = [
25    AgentTopic::Streams,
26    AgentTopic::Memory,
27    AgentTopic::Dlq,
28    AgentTopic::Audit,
29    AgentTopic::WorkflowJournal,
30];
31
32// Heartbeats only prove liveness now, so they expire after an hour.
33const HEARTBEAT_EXPIRY_MICROS: u64 = 3_600_000_000;
34
35const REPLY_BATCH: u32 = 1000;
36
37impl Laser {
38    /// Create the agent topics on the default stream, `partitions` each:
39    /// `agent.sessions` under `retention`, `agent.heartbeats` with a one-hour
40    /// expiry, and the satellites `agent.streams`, `agent.memory`,
41    /// `agent.dlq`, `agent.audit`, and `agent.workflow_journal`. The registry
42    /// topic is created by the first card or registry fact. Idempotent. A stream that already exists is used as
43    /// it is, so a pre-provisioned stream needs no stream permission.
44    /// `agent.control` is not created here, because only operators may send to
45    /// it. Requires a default stream (see [`Laser::connect_with_stream`]).
46    ///
47    /// The seven topics hold `7 * partitions` partitions on the server, and every partition costs it open files and memory. Keep `partitions` small on small tiers, and remove a stream you no longer need with [`Stream::delete`](crate::stream::Stream::delete).
48    ///
49    /// Warms a producer for every topic concurrently, so that cost is paid once
50    /// up front instead of lazily on a handler's first reply.
51    #[tracing::instrument(
52        target = "laser",
53        level = "info",
54        skip_all,
55        fields(operation = "bootstrap")
56    )]
57    pub async fn bootstrap(
58        &self,
59        partitions: u32,
60        retention: crate::agent::TopicRetention,
61    ) -> Result<(), LaserError> {
62        let stream = self.stream_required()?;
63        let client = self.client();
64        ensure_stream(&client, stream).await?;
65        ensure_topic_retained(
66            &client,
67            stream,
68            laser_wire::topics::AGENT_SESSIONS,
69            partitions,
70            retention.expiry(),
71            retention.max_size(),
72        )
73        .await?;
74        ensure_topic_retained(
75            &client,
76            stream,
77            laser_wire::topics::AGENT_HEARTBEATS,
78            partitions,
79            IggyExpiry::ExpireDuration(IggyDuration::from(HEARTBEAT_EXPIRY_MICROS)),
80            MaxTopicSize::ServerDefault,
81        )
82        .await?;
83        for topic in SATELLITE_TOPICS {
84            ensure_topic(&client, stream, &topic.topic_string(), partitions).await?;
85        }
86        let mut warming = tokio::task::JoinSet::new();
87        for topic in [AgentTopic::Sessions, AgentTopic::Heartbeats]
88            .into_iter()
89            .chain(SATELLITE_TOPICS)
90        {
91            let laser = self.clone();
92            let stream = stream.to_owned();
93            warming.spawn(async move { laser.producer_on(&stream, &topic.topic_string()).await });
94        }
95        while let Some(joined) = warming.join_next().await {
96            joined.map_err(|_| LaserError::Config("producer warm-up task panicked"))??;
97        }
98        info!(stream = %stream, partitions, "bootstrapped agent topology");
99        Ok(())
100    }
101
102    /// Append `payload` to an agent `topic`, stamping `provenance` as headers and keying the partition by conversation.
103    ///
104    /// The low-altitude form taking a caller-built [`Provenance`] verbatim.
105    /// Application code usually reads better as
106    /// [`laser.agent(id).send(..)`](crate::agent::AgentScope::send), which pins
107    /// the acting identity for you.
108    #[tracing::instrument(target = "laser", level = "debug", skip_all, fields(conversation = %provenance.conversation_id, topic = %topic.topic_string(), operation = "send"))]
109    pub async fn send_agent(
110        &self,
111        topic: AgentTopic<'_>,
112        payload: impl Into<Vec<u8>>,
113        provenance: &Provenance,
114    ) -> Result<(), LaserError> {
115        self.send_agent_as(ActionKind::Send, topic, payload, provenance)
116            .await
117            .map(|_| ())
118    }
119
120    // `send_agent` with the governed-action kind named by the caller, so the
121    // request path and the memory writes report what they are to the governor
122    // instead of all reading as plain sends.
123    pub(crate) async fn send_agent_as(
124        &self,
125        kind: ActionKind,
126        topic: AgentTopic<'_>,
127        payload: impl Into<Vec<u8>>,
128        provenance: &Provenance,
129    ) -> Result<SendMessagesResponse, LaserError> {
130        let mut headers: BTreeMap<HeaderKey, HeaderValue> = provenance.try_into()?;
131        let key = provenance.partition_key();
132        let topic_name = topic.topic_string();
133        stamp_broadcast_addressee(&mut headers, &topic_name)?;
134        let mut payload: Vec<u8> = payload.into();
135        let action = GovernedAction {
136            kind,
137            stream: self.stream_required()?,
138            topic: &topic_name,
139            source: provenance.agent.as_ref().map(|agent| agent.as_str()),
140            target: provenance
141                .target_agent_id
142                .as_ref()
143                .map(|agent| agent.as_str()),
144            conversation: Some(provenance.conversation_id),
145            correlation: provenance.correlation_id.as_deref(),
146            operation: None,
147            tool: None,
148            on_behalf_of: None,
149            purpose: None,
150            data_classification: None,
151            payload: &payload,
152            signed: false,
153            counters: ActionCounters::default(),
154        };
155        if let Some(modified) = self.govern(action).await? {
156            payload = modified;
157        }
158        // A declared per-agent topic layout moves a record addressed to a
159        // declared agent off the lane onto that agent's topic.
160        let destination = provenance
161            .target_agent_id
162            .as_ref()
163            .and_then(|target| self.agent_destination(&topic_name, None, Some(&target.wire_id())));
164        self.send_with_headers(
165            destination.as_deref().unwrap_or(&topic_name),
166            payload,
167            headers,
168            Some(&key),
169        )
170        .await
171    }
172
173    /// Redrive a dead-lettered message: read the original record at the
174    /// capsule's `source` position and republish its payload verbatim to that
175    /// stream and topic, partitioned by its conversation, so a fixed handler
176    /// reprocesses it. The idempotency header is re-keyed by the source
177    /// position (the consumer already observed the original key), so the copy
178    /// survives dedup while a double redrive of one capsule does not. Errors if
179    /// the source stream/topic no longer exists or the record has aged out of
180    /// the log's retention window.
181    ///
182    /// The capsule names its own source position, and any writer that can append
183    /// to the dead-letter topic can forge one. The redrive is therefore confined
184    /// to this connection's default stream: a capsule pointing anywhere else is
185    /// refused rather than copied into a stream the caller did not choose.
186    pub async fn redrive_dead_letter(&self, capsule: &AgentDeadLetter) -> Result<(), LaserError> {
187        let source = capsule.source;
188        let stream = Identifier::numeric(source.stream_id)?;
189        let topic = Identifier::numeric(source.topic_id)?;
190        let stream_name = self
191            .client()
192            .get_stream(&stream)
193            .await?
194            .ok_or_else(|| {
195                LaserError::Invalid(format!(
196                    "dead-letter source stream {} no longer exists",
197                    source.stream_id
198                ))
199            })?
200            .name;
201        let expected = self.stream_required()?;
202        if stream_name != expected {
203            return Err(LaserError::Invalid(format!(
204                "dead-letter capsule names stream `{stream_name}`, outside this connection's stream `{expected}`"
205            )));
206        }
207        let topic_name = self
208            .client()
209            .get_topic(&stream, &topic)
210            .await?
211            .ok_or_else(|| {
212                LaserError::Invalid(format!(
213                    "dead-letter source topic {} no longer exists",
214                    source.topic_id
215                ))
216            })?
217            .name;
218        let reader = Consumer::new(Identifier::named("laser-redrive")?);
219        let polled = self
220            .client()
221            .poll_messages(
222                &stream,
223                &topic,
224                Some(source.partition_id),
225                &reader,
226                &PollingStrategy::offset(source.offset),
227                1,
228                false,
229            )
230            .await?;
231        let original = polled
232            .messages
233            .into_iter()
234            .find(|message| message.header.offset == source.offset)
235            .ok_or_else(|| {
236                LaserError::Invalid(format!(
237                    "dead-letter source record at offset {} is no longer on the log",
238                    source.offset
239                ))
240            })?;
241        let mut headers = original.user_headers_map()?.unwrap_or_default();
242        // The consumer's deduplicator observed the original idempotency key when
243        // the message first arrived (before it dead-lettered), so a byte-verbatim
244        // redrive would be dropped as a duplicate by any consumer still holding
245        // that window. The redriven copy is re-keyed by the source position:
246        // deterministic, so redriving the same capsule twice still deduplicates,
247        // while the payload itself stays verbatim.
248        use std::str::FromStr;
249        let idempotency = HeaderKey::from_str(crate::provenance::keys::IDEMPOTENCY_KEY)
250            .map_err(|_| LaserError::Invalid("idempotency header key".to_owned()))?;
251        if let Some(existing) = headers.get(&idempotency) {
252            let redrive_key = format!(
253                "{}/redrive/{}-{}",
254                existing.as_str()?,
255                source.partition_id,
256                source.offset
257            );
258            headers.insert(
259                idempotency,
260                HeaderValue::from_str(&redrive_key)
261                    .map_err(|_| LaserError::Invalid("redrive idempotency value".to_owned()))?,
262            );
263        }
264        // Re-key by the original conversation so the redriven copy lands on the
265        // same partition and stays ordered with the rest of its conversation.
266        let key = Provenance::try_from(&original)
267            .ok()
268            .map(|provenance| provenance.partition_key());
269        let message = IggyMessage::builder()
270            .payload(original.payload)
271            .user_headers(headers)
272            .build()?;
273        self.send_batch_on(&stream_name, &topic_name, vec![message], key.as_deref())
274            .await
275            .map(|_| ())
276    }
277
278    /// Reassemble a chunk stream from the log: read `conversation` on `topic`,
279    /// take the `chunk` envelopes for `channel` in `sequence` order, and replay
280    /// them through a [`ChunkAssembler`] into the ordered [`StreamEvent`]s. This
281    /// is the log-native form of resuming or replaying a token stream. Offset
282    /// replay does what SSE replay cannot, so a finished stream reconstructs
283    /// deterministically after the fact with no external transcript store.
284    pub async fn reassemble_channel(
285        &self,
286        conversation: ConversationId,
287        topic: AgentTopic<'static>,
288        channel: ChannelId,
289    ) -> Result<Vec<StreamEvent>, LaserError> {
290        let messages = ContextAssembler::builder()
291            .conversation_id(conversation)
292            .topics(vec![topic])
293            .build()
294            .assemble(self)
295            .await?;
296        let mut chunks: Vec<_> = messages
297            .iter()
298            .filter_map(|message| message.envelope.as_ref())
299            .filter(|envelope| {
300                envelope.kind == AgentKind::Chunk && envelope.channel == Some(channel)
301            })
302            .collect();
303        // Within one conversation partition the server delivers in publish
304        // order, but a stream can be keyed onto several partitions, so ordering by
305        // `sequence` reassembles deterministically regardless.
306        chunks.sort_by_key(|envelope| envelope.sequence);
307        let mut assembler = ChunkAssembler::new();
308        let mut events = Vec::new();
309        for chunk in chunks {
310            events.extend(assembler.feed(chunk));
311        }
312        Ok(events)
313    }
314
315    // Where replies addressed to `requester` land: its declared topic when
316    // the stream declares per-agent topics and replies would ride the lane,
317    // else `reply_topic` itself.
318    pub(crate) fn reply_topic_for(
319        &self,
320        reply_topic: &AgentTopic<'_>,
321        requester: Option<&laser_wire::agent::AgentId>,
322    ) -> Result<iggy::prelude::Identifier, LaserError> {
323        let declared = (reply_topic.topic_string() == laser_wire::topics::AGENT_SESSIONS)
324            .then(|| requester.and_then(|agent| self.declared_topic(agent)))
325            .flatten();
326        Ok(match declared {
327            Some(topic) => iggy::prelude::Identifier::named(&topic)?,
328            None => reply_topic.as_identifier(),
329        })
330    }
331
332    /// A reply reader seeded at `reply_topic`'s current tail, for the synchronous
333    /// request/reply bridges. The caller builds it BEFORE sending the request, so
334    /// the scan reads only the reply (which lands after the send) instead of
335    /// walking the topic's history, then awaits it with
336    /// [`await_agdx_reply`](Self::await_agdx_reply).
337    pub(crate) async fn agdx_reply_reader(
338        &self,
339        reply_topic: AgentTopic<'_>,
340        requester: Option<&laser_wire::agent::AgentId>,
341    ) -> Result<AgentReplyReader, LaserError> {
342        let topic = self.reply_topic_for(&reply_topic, requester)?;
343        #[allow(unused_mut)]
344        let mut reader =
345            AgentReplyReader::new_at_tail(&self.client(), self.stream_required()?, topic).await?;
346        #[cfg(feature = "sign")]
347        {
348            reader.verifier = self.registry_verifier();
349        }
350        Ok(reader)
351    }
352
353    /// Wait on a pre-seeded `reader` for the AGDX `response`/`error` carrying
354    /// `correlation`, up to `timeout`. The reader is forward-advancing (offsets
355    /// only move forward, seeded at the tail before the request was sent), so the
356    /// reply topic is read incrementally, never re-scanned from the start. Used by
357    /// the synchronous bridge calls (MCP `tools/call`, human-input request).
358    pub(crate) async fn await_agdx_reply(
359        &self,
360        reader: &mut AgentReplyReader,
361        correlation: CorrelationId,
362        timeout: Duration,
363    ) -> Result<AgentEnvelope, LaserError> {
364        let deadline = Instant::now() + timeout;
365        loop {
366            // Checked before the scan and on every branch: a busy reply topic
367            // keeps returning `More`, so testing the deadline only once caught
368            // up would let a flooded topic defer it forever.
369            if Instant::now() >= deadline {
370                return Err(LaserError::Timeout("the AGDX reply"));
371            }
372            match reader.next_agdx_match(&self.client(), correlation).await? {
373                ReplyScan::Found(envelope) => return Ok(*envelope),
374                ReplyScan::More => continue,
375                ReplyScan::CaughtUp => sleep(Duration::from_millis(50)).await,
376            }
377        }
378    }
379
380    /// One forward pass over `reply_topic` for the AGDX `response`/`error`
381    /// carrying `correlation`: the answer if it has already landed, else `None`.
382    /// A point lookup (no waiting), for the stateless A2A `tasks/get`.
383    /// The scan is bounded: this lookup is reachable from an unauthenticated
384    /// bridge request and starts at the topic's head, so an ever-growing reply
385    /// topic would otherwise turn one request into an unbounded read.
386    #[cfg(feature = "a2a-bridge")]
387    pub(crate) async fn find_agdx_reply(
388        &self,
389        reply_topic: AgentTopic<'_>,
390        correlation: CorrelationId,
391    ) -> Result<ReplyLookup, LaserError> {
392        const MAX_LOOKUP_PASSES: usize = 32;
393        let mut reader =
394            AgentReplyReader::new(self.stream_required()?, reply_topic.as_identifier())?;
395        #[cfg(feature = "sign")]
396        {
397            reader.verifier = self.registry_verifier();
398        }
399        for _ in 0..MAX_LOOKUP_PASSES {
400            match reader.next_agdx_match(&self.client(), correlation).await? {
401                ReplyScan::Found(envelope) => return Ok(ReplyLookup::Found(envelope)),
402                ReplyScan::More => continue,
403                ReplyScan::CaughtUp => return Ok(ReplyLookup::NotFound),
404            }
405        }
406        // The bound ended the scan before the topic did, so the answer may
407        // still be further on. That is not proof the task is still working.
408        Ok(ReplyLookup::Incomplete)
409    }
410
411    /// A fresh child conversation of `parent`, carrying its parent/root ids for causality.
412    pub fn spawn_subconversation(&self, parent: &Provenance, author: &AgentId) -> Provenance {
413        let root = parent
414            .root_conversation_id
415            .unwrap_or(parent.conversation_id);
416        Provenance::builder()
417            .conversation_id(ConversationId::new())
418            .parent_conversation_id(parent.conversation_id)
419            .root_conversation_id(root)
420            .agent(author.clone())
421            .build()
422    }
423
424    /// Send a request and await its correlated reply. Correlation is a fresh
425    /// `correlation_id` (Ulid, the `agdx.corr` header) stamped on the request. The
426    /// responder echoes it back via `AgentCtx::respond`, and this scans the reply
427    /// topic for a matching correlation until the timeout. `conversation_id` alone
428    /// is NOT used for correlation, so a forged reply that guesses the conversation
429    /// id cannot hijack the request. A caller-supplied `provenance.correlation_id`
430    /// is used as-is. The business `idempotency_key` is left untouched, so setting
431    /// a real dedup key and retrying no longer cross-matches replies.
432    ///
433    /// The low-altitude form taking a caller-built [`Provenance`] verbatim.
434    /// Application code usually reads better as
435    /// [`laser.agent(id).ask(..)`](crate::agent::AgentScope::ask), which pins
436    /// the acting identity for you.
437    #[tracing::instrument(target = "laser", level = "debug", skip_all, fields(conversation = %provenance.conversation_id, topic = %request_topic.topic_string(), operation = "ask"))]
438    pub async fn request(
439        &self,
440        request_topic: AgentTopic<'_>,
441        reply_topic: AgentTopic<'_>,
442        payload: impl Into<Vec<u8>>,
443        provenance: &Provenance,
444        timeout: Duration,
445    ) -> Result<AgentMessage, LaserError> {
446        let correlation = provenance
447            .correlation_id
448            .clone()
449            .unwrap_or_else(|| ulid::Ulid::generate().to_string());
450        let mut provenance = provenance.clone();
451        provenance.correlation_id = Some(correlation.clone());
452        let same_topic = request_topic.topic_string() == reply_topic.topic_string();
453        // Register with the shared reply dispatcher BEFORE sending: the reply
454        // cannot exist until the request is sent, and the hub reads the topic once
455        // for every waiter, so N concurrent requests share one read stream instead
456        // of each scanning the topic.
457        let requester = provenance
458            .agent
459            .as_ref()
460            .map(crate::types::AgentId::wire_id);
461        let topic = self.reply_topic_for(&reply_topic, requester.as_ref())?;
462        let hub = self.reply_hub(&AgentTopic::Custom(&topic)).await?;
463        let ticket = hub.subscribe(
464            correlation,
465            crate::agent::replies::ExpectedReply {
466                session: provenance.conversation_id,
467                requester: provenance
468                    .agent
469                    .as_ref()
470                    .map(|agent| agent.as_str().to_owned()),
471            },
472            provenance
473                .target_agent_id
474                .as_ref()
475                .map(|target| target.as_str().to_owned()),
476        );
477        let sent = self
478            .send_agent_as(ActionKind::Request, request_topic, payload, &provenance)
479            .await?;
480        let confirmation = sent.confirmations.first().ok_or_else(|| {
481            LaserError::Protocol("request send had no committed message address".to_owned())
482        })?;
483        ticket.arm(
484            same_topic.then(|| MessageId::new(confirmation.partition_id, confirmation.base_offset)),
485        );
486        ticket.wait(timeout).await
487    }
488
489    /// Has `target` committed past the log position `at`? The consumption
490    /// acknowledgment: a proof-of-pickup that distinguishes "the target never
491    /// consumed the message" from "it consumed but has not finished," by
492    /// comparing the target consumer's stored offset against the message's
493    /// publish position. Composes Iggy `get_consumer_offset`, where an
494    /// agent's group is named by its agent id.
495    ///
496    /// A no-stored-offset answer is reported as [`ConsumptionStatus::NotYetConsumed`].
497    /// The server returns that same empty answer both for a consumer that has
498    /// genuinely never committed and for a topic-read it could not authorize or
499    /// resolve, so a caller using this to decide a reassignment should hold
500    /// verified topic-read access. Otherwise an auth or resolution misconfig reads
501    /// as "not consumed" and could drive a needless reassignment.
502    pub async fn consumed(
503        &self,
504        target: ConsumerRef,
505        at: LogPosition,
506    ) -> Result<ConsumptionStatus, LaserError> {
507        let consumer = match &target {
508            ConsumerRef::Group(name) => Consumer::group(Identifier::named(name.as_str())?),
509            ConsumerRef::Consumer(id) => Consumer::new(Identifier::named(id)?),
510        };
511        let info = self
512            .client()
513            .get_consumer_offset(
514                &consumer,
515                &Identifier::numeric(at.stream_id)?,
516                &Identifier::numeric(at.topic_id)?,
517                Some(at.partition_id),
518            )
519            .await?;
520        Ok(match info {
521            Some(offset) if offset.stored_offset >= at.offset => {
522                let dispatch = match &target {
523                    ConsumerRef::Group(name) => self.dispatch_at(name.as_str(), at).await?,
524                    ConsumerRef::Consumer(_) => None,
525                };
526                match dispatch {
527                    Some(dispatch) if dispatch != Dispatch::Work => ConsumptionStatus::Skipped {
528                        committed: offset.stored_offset,
529                        head: offset.current_offset,
530                        dispatch,
531                    },
532                    _ => ConsumptionStatus::Consumed {
533                        committed: offset.stored_offset,
534                        head: offset.current_offset,
535                    },
536                }
537            }
538            Some(offset) => ConsumptionStatus::NotYetConsumed {
539                behind_by: at.offset.saturating_sub(offset.stored_offset),
540            },
541            None => ConsumptionStatus::NotYetConsumed {
542                behind_by: at.offset.saturating_add(1),
543            },
544        })
545    }
546}
547
548/// Whether a target consumer has committed past a published message's position.
549/// The consumption-acknowledgment result.
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum ConsumptionStatus {
552    /// The target's stored offset is still behind the message by `behind_by`.
553    NotYetConsumed { behind_by: u64 },
554    /// The target has committed past the message: `committed` is its stored
555    /// offset, `head` the partition head at the time of the probe.
556    Consumed { committed: u64, head: u64 },
557    /// The target agent's group committed past the message without handling
558    /// it, because the record is not work for that agent. `dispatch` says why.
559    Skipped {
560        committed: u64,
561        head: u64,
562        dispatch: Dispatch,
563    },
564}
565
566impl Laser {
567    // How the agent named by `group` classifies the record at `at`, or `None`
568    // when the record is gone, does not decode, or the group is not an agent.
569    async fn dispatch_at(
570        &self,
571        group: &str,
572        at: LogPosition,
573    ) -> Result<Option<Dispatch>, LaserError> {
574        let Ok(me) = group.parse::<laser_wire::agent::AgentId>() else {
575            return Ok(None);
576        };
577        let stream = Identifier::numeric(at.stream_id)?;
578        let topic = Identifier::numeric(at.topic_id)?;
579        let Some(details) = self.client().get_topic(&stream, &topic).await? else {
580            return Ok(None);
581        };
582        let reader = Consumer::new(Identifier::named("laser-consumed")?);
583        let polled = self
584            .client()
585            .poll_messages(
586                &stream,
587                &topic,
588                Some(at.partition_id),
589                &reader,
590                &PollingStrategy::offset(at.offset),
591                1,
592                false,
593            )
594            .await?;
595        let Some(message) = polled
596            .messages
597            .iter()
598            .find(|message| message.header.offset == at.offset)
599        else {
600            return Ok(None);
601        };
602        let Ok(record) = crate::agent::consumer::decode_agent_record(message, u64::MAX) else {
603            return Ok(None);
604        };
605        Ok(Some(match record.envelope {
606            Some(envelope) => classify(&envelope, &details.name, &me, HandledOperations::Any),
607            None if record
608                .provenance
609                .target_agent_id
610                .as_ref()
611                .is_some_and(|target| target.as_str() != me.as_str()) =>
612            {
613                Dispatch::Foreign
614            }
615            None => Dispatch::Work,
616        }))
617    }
618}
619
620/// Which consumer to probe in [`Laser::consumed`]: a deployment consumer group
621/// or a named individual consumer.
622#[derive(Debug, Clone)]
623pub enum ConsumerRef {
624    Group(ConsumerGroupName),
625    Consumer(String),
626}
627
628// The agentic reply reader: matches by `agdx.idem` (fresh per-request
629// nonce stamped by `Laser::request`), decodes `Provenance`, returns
630// `AgentMessage`. The generic core has its own reader matching
631// `agdx.corr` and returning `Message`. Caches `partitions_count` on
632// first call so a 30s timeout does not do 300 `get_topic` round-trips.
633pub(crate) struct AgentReplyReader {
634    stream: Identifier,
635    topic: Identifier,
636    consumer: Consumer,
637    offsets: Vec<u64>,
638    partitions: Option<u32>,
639    /// The enrolled-key verifier a contract checks an AGDX reply against. When
640    /// set, a `Working` ack or terminal is honored only if its signature verifies,
641    /// so a peer that can read the reply topic cannot forge a terminal to
642    /// short-circuit the contract by echoing its correlation. Envelope-less
643    /// replies are refused outright under a verifier (they carry nothing to
644    /// verify), mirroring the consumer's fail-closed posture.
645    #[cfg(feature = "sign")]
646    verifier: Option<std::sync::Arc<crate::sign::KeyRegistry>>,
647    /// The principal the verified reply signer must equal, normally the resolved
648    /// contract target, so a valid signature from a different enrolled key
649    /// cannot answer for the routed agent.
650    #[cfg(feature = "sign")]
651    pub(crate) expected_signer: Option<String>,
652}
653
654impl AgentReplyReader {
655    fn new(stream: &str, topic: Identifier) -> Result<Self, LaserError> {
656        Ok(Self {
657            stream: Identifier::named(stream)?,
658            topic,
659            consumer: Consumer::new(Identifier::named("laser-agent-reply-reader")?),
660            offsets: Vec::new(),
661            partitions: None,
662            #[cfg(feature = "sign")]
663            verifier: None,
664            #[cfg(feature = "sign")]
665            expected_signer: None,
666        })
667    }
668
669    /// Whether an AGDX reply envelope is honored: always when no verifier is
670    /// enrolled, otherwise only when its signature verifies against an enrolled
671    /// key AND the verified principal is the expected signer (when one is
672    /// bound), so any other enrolled key cannot answer for the routed target.
673    #[cfg(feature = "sign")]
674    fn verified_principal(
675        &self,
676        envelope: &AgentEnvelope,
677        context: Option<&laser_wire::agent::SignatureContext>,
678        observed_at_micros: u64,
679    ) -> Result<Option<String>, ()> {
680        match &self.verifier {
681            Some(registry) => match context.and_then(|context| {
682                registry
683                    .verify_observed_at(envelope, context, observed_at_micros)
684                    .ok()
685            }) {
686                Some(verified)
687                    if self
688                        .expected_signer
689                        .as_deref()
690                        .is_none_or(|expected| expected == verified.principal) =>
691                {
692                    Ok(Some(verified.principal))
693                }
694                Some(_) | None => Err(()),
695            },
696            None => Ok(None),
697        }
698    }
699
700    #[cfg(not(feature = "sign"))]
701    fn verified_principal(
702        &self,
703        _envelope: &AgentEnvelope,
704        _context: Option<&laser_wire::agent::SignatureContext>,
705        _observed_at_micros: u64,
706    ) -> Result<Option<String>, ()> {
707        Ok(None)
708    }
709
710    /// Whether an envelope-less correlated reply may complete a contract: only
711    /// when no verifier is enrolled. A plain reply carries no signature, so
712    /// with verification on it is refused, the same fail-closed posture the
713    /// reliable consumer takes before dispatch.
714    #[cfg(feature = "sign")]
715    fn plain_reply_ok(&self) -> bool {
716        self.verifier.is_none()
717    }
718
719    #[cfg(not(feature = "sign"))]
720    fn plain_reply_ok(&self) -> bool {
721        true
722    }
723
724    /// A reader seeded at the reply topic's current tail, so the scan reads only
725    /// records appended after this point instead of walking the topic's history
726    /// from offset zero. The caller MUST construct it before sending the request
727    /// it awaits, so the reply (which cannot exist before the request is sent)
728    /// always lands at or after the seeded tail and is never missed.
729    async fn new_at_tail(
730        client: &IggyClient,
731        stream: &str,
732        topic: Identifier,
733    ) -> Result<Self, LaserError> {
734        let mut reader = Self::new(stream, topic)?;
735        let partitions = match client.get_topic(&reader.stream, &reader.topic).await? {
736            Some(details) => crate::poll::bounded_partitions(details.partitions_count),
737            // The topic does not exist yet: leave the reader unseeded (offsets at
738            // zero), so once it is created the scan still finds the reply.
739            None => return Ok(reader),
740        };
741        reader.partitions = Some(partitions);
742        reader.offsets = vec![0; partitions as usize];
743        for partition in 0..partitions {
744            let polled = client
745                .poll_messages(
746                    &reader.stream,
747                    &reader.topic,
748                    Some(partition),
749                    &reader.consumer,
750                    &PollingStrategy::last(),
751                    1,
752                    false,
753                )
754                .await?;
755            // Resume after the last existing record. An empty partition stays at 0.
756            if let Some(last) = polled.messages.last() {
757                reader.offsets[partition as usize] = last.header.offset.saturating_add(1);
758            }
759        }
760        Ok(reader)
761    }
762
763    // Forward scan for the AGDX `response`/`error` carrying `correlation`: drain
764    // one batch per partition from the advancing offsets, decode each envelope,
765    // and return the first match. `Found` short-circuits, `More` means messages
766    // were read without a match (drain again), `CaughtUp` means nothing new.
767    // Never re-reads from 0: offsets only move forward across calls.
768    async fn next_agdx_match(
769        &mut self,
770        client: &IggyClient,
771        correlation: CorrelationId,
772    ) -> Result<ReplyScan, LaserError> {
773        let partitions = match self.partitions {
774            Some(value) => value,
775            None => {
776                let Some(details) = client.get_topic(&self.stream, &self.topic).await? else {
777                    return Ok(ReplyScan::CaughtUp);
778                };
779                self.partitions = Some(details.partitions_count);
780                details.partitions_count
781            }
782        };
783        if (self.offsets.len() as u32) < partitions {
784            self.offsets.resize(partitions as usize, 0);
785        }
786        let mut read_any = false;
787        for partition in 0..partitions {
788            let from = self.offsets[partition as usize];
789            let batch = crate::poll::drain_partition(
790                client,
791                &self.stream,
792                &self.topic,
793                &self.consumer,
794                crate::poll::DrainRange::open(partition, from),
795                REPLY_BATCH,
796            )
797            .await?;
798            self.offsets[partition as usize] = batch.next_offset;
799            for message in batch.messages {
800                read_any = true;
801                let Ok(decoded) = crate::agent::consumer::decode_agent_record(
802                    &message,
803                    laser_wire::agent::features::NONE,
804                ) else {
805                    continue;
806                };
807                if let Some(envelope) = decoded.envelope
808                    && envelope.correlation == Some(correlation)
809                    && matches!(envelope.kind, AgentKind::Response | AgentKind::Error)
810                    && self
811                        .verified_principal(
812                            &envelope,
813                            decoded.signature_context.as_ref(),
814                            decoded.observed_at_micros,
815                        )
816                        .is_ok()
817                {
818                    return Ok(ReplyScan::Found(Box::new(envelope)));
819                }
820            }
821        }
822        Ok(if read_any {
823            ReplyScan::More
824        } else {
825            ReplyScan::CaughtUp
826        })
827    }
828}
829
830// The outcome of one forward scan step in [`AgentReplyReader::next_agdx_match`].
831// The matched envelope is boxed so the common no-match variants stay small.
832enum ReplyScan {
833    Found(Box<AgentEnvelope>),
834    More,
835    CaughtUp,
836}
837
838impl AgentReplyReader {
839    // One forward pass for the task-contract signals carrying `correlation`: the
840    // `Working` ack (an AGDX `status` the target emits on pickup) and the terminal
841    // (an AGDX `response`/`error`, or a plain `send_agent` reply matched by the
842    // correlation echoed into its idempotency key). Processes the whole drained
843    // batch so a `Working` and a terminal in the same batch are both observed,
844    // never skipping the terminal. Offsets only move forward across calls.
845    pub(crate) async fn poll_contract(
846        &mut self,
847        client: &IggyClient,
848        correlation: CorrelationId,
849    ) -> Result<ContractPass, LaserError> {
850        let correlation_key = correlation.to_string();
851        let partitions = match self.partitions {
852            Some(value) => value,
853            None => {
854                let Some(details) = client.get_topic(&self.stream, &self.topic).await? else {
855                    return Ok(ContractPass::default());
856                };
857                self.partitions = Some(details.partitions_count);
858                details.partitions_count
859            }
860        };
861        if (self.offsets.len() as u32) < partitions {
862            self.offsets.resize(partitions as usize, 0);
863        }
864        let mut pass = ContractPass::default();
865        for partition in 0..partitions {
866            let from = self.offsets[partition as usize];
867            let batch = crate::poll::drain_partition(
868                client,
869                &self.stream,
870                &self.topic,
871                &self.consumer,
872                crate::poll::DrainRange::open(partition, from),
873                REPLY_BATCH,
874            )
875            .await?;
876            self.offsets[partition as usize] = batch.next_offset;
877            for message in batch.messages {
878                pass.read_any = true;
879                let Ok(decoded) = crate::agent::consumer::decode_agent_record(
880                    &message,
881                    laser_wire::agent::features::NONE,
882                ) else {
883                    continue;
884                };
885                let provenance = decoded.provenance;
886                let envelope = decoded.envelope;
887                // Classify the correlated record into a small Copy signal first, so
888                // the envelope borrow ends before the record is moved into a reply.
889                let signal = match &envelope {
890                    // A forged reply that merely echoes the correlation cannot
891                    // short-circuit the contract: with a verifier enrolled, an
892                    // unsigned or wrongly-signed reply is ignored.
893                    Some(agdx) if agdx.correlation == Some(correlation) => {
894                        let Ok(verified_principal) = self.verified_principal(
895                            agdx,
896                            decoded.signature_context.as_ref(),
897                            decoded.observed_at_micros,
898                        ) else {
899                            continue;
900                        };
901                        if agdx.kind == AgentKind::Status
902                            && agdx.operation.as_deref() == Some(OPERATION_TASK)
903                            && agdx.task_state == Some(TaskState::Working)
904                        {
905                            ContractSignal::Working
906                        } else {
907                            match agdx.kind {
908                                AgentKind::Response => {
909                                    ContractSignal::Completed(verified_principal)
910                                }
911                                AgentKind::Error => ContractSignal::Failed(verified_principal),
912                                _ => ContractSignal::Ignore,
913                            }
914                        }
915                    }
916                    // A plain `send_agent` reply (no envelope), correlated by the
917                    // echoed correlation (no error discriminant, so any such reply
918                    // is a completion). Gated to the envelope-less shape so an AGDX
919                    // record that merely echoes the correlation on a different one
920                    // can never be mis-read as this contract's terminal, and to the
921                    // no-verifier case: under a verifier a plain reply carries
922                    // nothing to verify and is refused.
923                    None if provenance.correlation_id.as_deref()
924                        == Some(correlation_key.as_str())
925                        && self.plain_reply_ok() =>
926                    {
927                        ContractSignal::Completed(None)
928                    }
929                    _ => ContractSignal::Ignore,
930                };
931                let reply = |verified_principal| AgentMessage {
932                    provenance,
933                    id: MessageId::new(partition, message.header.offset),
934                    payload: message.payload.to_vec(),
935                    envelope,
936                    content_type: decoded.content_type,
937                    verified_principal,
938                };
939                match signal {
940                    ContractSignal::Working => pass.consumed = true,
941                    ContractSignal::Completed(principal) => {
942                        pass.terminal = Some(ContractTerminal::Completed(reply(principal)));
943                        return Ok(pass);
944                    }
945                    ContractSignal::Failed(principal) => {
946                        pass.terminal = Some(ContractTerminal::Failed(reply(principal)));
947                        return Ok(pass);
948                    }
949                    ContractSignal::Ignore => {}
950                }
951            }
952        }
953        Ok(pass)
954    }
955}
956
957// One forward pass of the task-contract reply scan: whether the `Working` ack was
958// seen, the terminal if it landed, and whether any record was read at all.
959#[derive(Default)]
960pub(crate) struct ContractPass {
961    pub consumed: bool,
962    pub terminal: Option<ContractTerminal>,
963    pub read_any: bool,
964}
965
966// A task-contract terminal reply: the target's completion or its failure.
967pub(crate) enum ContractTerminal {
968    Completed(AgentMessage),
969    Failed(AgentMessage),
970}
971
972// One correlated record's contribution to the contract scan, computed while the
973// envelope is borrowed so the record can then be moved into a reply.
974#[derive(Clone)]
975enum ContractSignal {
976    Working,
977    Completed(Option<String>),
978    Failed(Option<String>),
979    Ignore,
980}
981
982/// Every record on a shared session topic carries `agdx.to`, `*` when the
983/// caller named no target, so a role group bound to the addressee filter on a
984/// deployment that serves group-aware reads still receives it. Open Apache
985/// Iggy classifies on the client and already delivered untargeted records.
986/// Other topics keep the caller's headers verbatim.
987pub(crate) fn stamp_broadcast_addressee(
988    headers: &mut BTreeMap<HeaderKey, HeaderValue>,
989    topic: &str,
990) -> Result<(), LaserError> {
991    if topic != laser_wire::topics::AGENT_SESSIONS && topic != laser_wire::topics::AGENT_CONTROL {
992        return Ok(());
993    }
994    let key = HeaderKey::from_str(laser_wire::headers::TARGET_AGENT_ID)?;
995    let broadcast = HeaderValue::from_str(laser_wire::headers::Addressee::Broadcast.as_str())?;
996    headers.entry(key).or_insert(broadcast);
997    Ok(())
998}
999
1000/// What a bounded reply lookup established.
1001#[cfg(feature = "a2a-bridge")]
1002pub(crate) enum ReplyLookup {
1003    Found(Box<AgentEnvelope>),
1004    /// The whole reply topic was read and no answer carries the correlation.
1005    NotFound,
1006    /// The scan bound ended before the topic did.
1007    Incomplete,
1008}
1009
1010#[cfg(test)]
1011mod addressee_tests {
1012    use super::*;
1013
1014    fn headers_with_target(target: Option<&str>) -> BTreeMap<HeaderKey, HeaderValue> {
1015        let mut headers = BTreeMap::new();
1016        if let Some(target) = target {
1017            headers.insert(
1018                HeaderKey::from_str(laser_wire::headers::TARGET_AGENT_ID).expect("key"),
1019                HeaderValue::from_str(target).expect("value"),
1020            );
1021        }
1022        headers
1023    }
1024
1025    fn addressee(headers: &BTreeMap<HeaderKey, HeaderValue>) -> Option<String> {
1026        headers
1027            .get(&HeaderKey::from_str(laser_wire::headers::TARGET_AGENT_ID).expect("key"))
1028            .map(|value| value.as_str().expect("text addressee").to_owned())
1029    }
1030
1031    #[test]
1032    fn given_an_untargeted_send_on_a_session_topic_when_stamped_then_should_broadcast() {
1033        for topic in [
1034            laser_wire::topics::AGENT_SESSIONS,
1035            laser_wire::topics::AGENT_CONTROL,
1036        ] {
1037            let mut headers = headers_with_target(None);
1038            stamp_broadcast_addressee(&mut headers, topic).expect("stamp");
1039            assert_eq!(addressee(&headers).as_deref(), Some("*"), "{topic}");
1040        }
1041    }
1042
1043    #[test]
1044    fn given_a_targeted_send_when_stamped_then_should_keep_the_target() {
1045        let mut headers = headers_with_target(Some("planner"));
1046        stamp_broadcast_addressee(&mut headers, laser_wire::topics::AGENT_SESSIONS).expect("stamp");
1047        assert_eq!(addressee(&headers).as_deref(), Some("planner"));
1048    }
1049
1050    #[test]
1051    fn given_an_untargeted_send_on_another_topic_when_stamped_then_should_leave_headers_alone() {
1052        let mut headers = headers_with_target(None);
1053        stamp_broadcast_addressee(&mut headers, "commands").expect("stamp");
1054        assert_eq!(addressee(&headers), None);
1055        assert!(headers.is_empty());
1056    }
1057}