Skip to main content

klieo_core/
agent.rs

1//! `Agent` trait and `AgentContext`.
2
3use crate::bus::{JobQueue, KvStore, Pubsub, RequestReply};
4use crate::ids::RunId;
5use crate::llm::{LlmClient, ToolDef};
6use crate::memory::{EpisodicMemory, LongTermMemory, ShortTermMemory};
7use crate::redact::AuditRedactor;
8use crate::tool::ToolInvoker;
9use async_trait::async_trait;
10use serde::de::DeserializeOwned;
11use serde::Serialize;
12use std::sync::Arc;
13use tokio_util::sync::CancellationToken;
14
15/// Step-level event emitted by the runtime during [`Agent::run`].
16/// Wire shape is transport-agnostic; MCP HTTP maps each variant
17/// to a `notifications/progress` JSON-RPC notification.
18///
19/// Default [`AgentContext::progress`] is `None`, so emission is a
20/// no-op for callers that don't opt in. Transports opt in by
21/// passing a `broadcast::Sender` when constructing the context.
22#[derive(Clone, Debug, Serialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum AgentEvent {
26    /// LLM call about to be issued.
27    LlmCallStarted,
28    /// LLM call returned a response. `tokens` is `prompt + completion`
29    /// when the provider streams a usage payload on the final chunk;
30    /// `0` when the provider does not emit usage in stream mode.
31    LlmCallCompleted {
32        /// Total token count (`prompt_tokens + completion_tokens`).
33        /// Zero when the provider does not surface usage in stream mode.
34        tokens: u32,
35        /// Wall-clock duration in milliseconds.
36        latency_ms: u64,
37    },
38    /// Tool dispatch begun for `name`.
39    ToolCallStarted {
40        /// Name of the tool being dispatched.
41        name: String,
42    },
43    /// Tool dispatch returned. `ok = false` means the tool
44    /// errored; the wire-level redaction policy decides what to
45    /// surface.
46    ToolCallCompleted {
47        /// Name of the tool that was dispatched.
48        name: String,
49        /// `true` if the tool call succeeded.
50        ok: bool,
51    },
52    /// Agent reached its final assistant response.
53    Completed,
54    /// Agent failed terminally. `reason` is a sanitised summary;
55    /// inner error chain is logged server-side by the runtime's
56    /// existing `tracing::error!` path on the `Err` return.
57    Failed {
58        /// Sanitised failure reason suitable for wire transmission.
59        reason: String,
60    },
61    /// Run suspended at a step awaiting human approval (ADR-045).
62    Suspended {
63        /// Reason supplied by the `ReviewPolicy`.
64        reason: String,
65    },
66}
67
68/// Borrow-free agent execution context. Holds `Arc<dyn …>` so it can be
69/// cloned freely across `tokio::spawn` boundaries (`'static` requirement).
70#[derive(Clone)]
71#[non_exhaustive]
72pub struct AgentContext {
73    /// LLM provider.
74    pub llm: Arc<dyn LlmClient>,
75    /// Short-term conversation memory.
76    pub short_term: Arc<dyn ShortTermMemory>,
77    /// Long-term semantic memory.
78    pub long_term: Arc<dyn LongTermMemory>,
79    /// Episodic event log.
80    pub episodic: Arc<dyn EpisodicMemory>,
81    /// Pub/sub bus.
82    pub pubsub: Arc<dyn Pubsub>,
83    /// KV store.
84    pub kv: Arc<dyn KvStore>,
85    /// Synchronous request/reply.
86    pub request_reply: Arc<dyn RequestReply>,
87    /// Job queue.
88    pub jobs: Arc<dyn JobQueue>,
89    /// Tool dispatcher.
90    pub tools: Arc<dyn ToolInvoker>,
91    /// Stable id for this run.
92    pub run_id: RunId,
93    /// Cooperative cancellation token. Runtime checks between steps.
94    pub cancel: CancellationToken,
95    /// Agent name; recorded in episodic events. Caller must set this
96    /// before invoking the runtime — typically from `Agent::name()`.
97    pub agent_name: String,
98    /// Optional fan-out channel for step-level events. When `Some`,
99    /// the runtime emits one [`AgentEvent`] per LLM call, tool call,
100    /// and terminal transition. Caller (e.g. MCP HTTP transport)
101    /// owns the receiver and serialises events to the wire.
102    ///
103    /// Default `None` — existing single-shot callers see no
104    /// behaviour change. Best-effort send; dropped receivers are
105    /// silently ignored.
106    pub progress: Option<tokio::sync::broadcast::Sender<AgentEvent>>,
107    /// Redactor applied to audit-flagged tools' recorded args/results.
108    /// Non-public so adding it stays SemVer-minor on the
109    /// `#[non_exhaustive]`-less struct; set only via
110    /// [`AgentContextBuilder::audit_redactor`]. `None` ⇒ dispatch uses
111    /// the fail-closed [`crate::opaque_digest`] fallback for flagged
112    /// tools.
113    pub(crate) audit_redactor: Option<Arc<dyn AuditRedactor>>,
114    /// Derived non-PII attribution label for the caller driving this
115    /// run.
116    ///
117    /// `Some` ⇒ runtime records an [`crate::Episode::RunAttributed`]
118    /// next to [`crate::Episode::Started`] so the audit trail can
119    /// attribute the run to its driving tenant. The raw principal is
120    /// never stored here — that stays in server-side tracing —
121    /// and the label is never appended to short-term memory or any
122    /// LLM-visible message.
123    ///
124    /// Non-public; install via [`Self::with_tenant_label`] or
125    /// [`AgentContextBuilder::tenant_label`].
126    pub(crate) tenant_label: Option<String>,
127    /// Opaque cross-hop provenance anchor — the caller's own
128    /// provenance chain-entry id, supplied by an authenticated external
129    /// caller over MCP.
130    ///
131    /// `Some` ⇒ runtime records an [`crate::Episode::RunOrigin`] next to
132    /// [`crate::Episode::Started`] so klieo→klieo composition can be
133    /// stitched across deployments. Recorded verbatim and treated as a
134    /// caller-asserted, unverified claim; never appended to short-term
135    /// memory or any LLM-visible message. Inherited by child runs so the
136    /// whole local run tree shares one external origin.
137    ///
138    /// Non-public; install via [`Self::with_parent_anchor`].
139    pub(crate) parent_anchor: Option<String>,
140}
141
142/// Error returned when a required field is missing from [`AgentContextBuilder`].
143#[derive(Debug, thiserror::Error)]
144#[non_exhaustive]
145pub enum AgentContextBuilderError {
146    /// A required builder field was not set before calling `build()`.
147    #[error("required field missing: {0}")]
148    MissingField(&'static str),
149}
150
151/// Fluent builder for [`AgentContext`].
152///
153/// All fields except `run_id` and `cancel` are required. Omitting
154/// `run_id` defaults to [`RunId::new()`]; omitting `cancel` defaults
155/// to a fresh [`CancellationToken`].
156///
157/// ```
158/// # use klieo_core::agent::{AgentContext, AgentContextBuilderError};
159/// # use klieo_core::test_utils::fake_context;
160/// // A full build is exercised in tests; this snippet shows the chaining shape.
161/// let result: Result<AgentContext, AgentContextBuilderError> = AgentContext::builder()
162///     .agent_name("my-agent")
163///     .build();
164/// assert!(result.is_err()); // other required fields are missing
165/// ```
166#[derive(Default)]
167pub struct AgentContextBuilder {
168    llm: Option<Arc<dyn LlmClient>>,
169    short_term: Option<Arc<dyn ShortTermMemory>>,
170    long_term: Option<Arc<dyn LongTermMemory>>,
171    episodic: Option<Arc<dyn EpisodicMemory>>,
172    pubsub: Option<Arc<dyn Pubsub>>,
173    kv: Option<Arc<dyn KvStore>>,
174    request_reply: Option<Arc<dyn RequestReply>>,
175    jobs: Option<Arc<dyn JobQueue>>,
176    tools: Option<Arc<dyn ToolInvoker>>,
177    run_id: Option<RunId>,
178    cancel: Option<CancellationToken>,
179    agent_name: Option<String>,
180    audit_redactor: Option<Arc<dyn AuditRedactor>>,
181    tenant_label: Option<String>,
182}
183
184impl AgentContextBuilder {
185    /// Set the LLM client.
186    pub fn llm(mut self, v: Arc<dyn LlmClient>) -> Self {
187        self.llm = Some(v);
188        self
189    }
190
191    /// Set the short-term memory store.
192    pub fn short_term(mut self, v: Arc<dyn ShortTermMemory>) -> Self {
193        self.short_term = Some(v);
194        self
195    }
196
197    /// Set the long-term memory store.
198    pub fn long_term(mut self, v: Arc<dyn LongTermMemory>) -> Self {
199        self.long_term = Some(v);
200        self
201    }
202
203    /// Set the episodic memory store.
204    pub fn episodic(mut self, v: Arc<dyn EpisodicMemory>) -> Self {
205        self.episodic = Some(v);
206        self
207    }
208
209    /// Set the pub/sub bus.
210    pub fn pubsub(mut self, v: Arc<dyn Pubsub>) -> Self {
211        self.pubsub = Some(v);
212        self
213    }
214
215    /// Set the KV store.
216    pub fn kv(mut self, v: Arc<dyn KvStore>) -> Self {
217        self.kv = Some(v);
218        self
219    }
220
221    /// Set the request/reply bus.
222    pub fn request_reply(mut self, v: Arc<dyn RequestReply>) -> Self {
223        self.request_reply = Some(v);
224        self
225    }
226
227    /// Set the job queue.
228    pub fn jobs(mut self, v: Arc<dyn JobQueue>) -> Self {
229        self.jobs = Some(v);
230        self
231    }
232
233    /// Set the tool invoker.
234    pub fn tools(mut self, v: Arc<dyn ToolInvoker>) -> Self {
235        self.tools = Some(v);
236        self
237    }
238
239    /// Override the run ID. Defaults to [`RunId::new()`] when omitted.
240    pub fn run_id(mut self, v: RunId) -> Self {
241        self.run_id = Some(v);
242        self
243    }
244
245    /// Override the cancellation token. Defaults to a fresh token when omitted.
246    pub fn cancel(mut self, v: CancellationToken) -> Self {
247        self.cancel = Some(v);
248        self
249    }
250
251    /// Set the agent name (required).
252    pub fn agent_name(mut self, v: impl Into<String>) -> Self {
253        self.agent_name = Some(v.into());
254        self
255    }
256
257    /// Install the audit redactor applied to audit-flagged tools'
258    /// recorded args/results. When omitted, dispatch falls back to the
259    /// fail-closed [`crate::opaque_digest`] for flagged tools.
260    pub fn audit_redactor(mut self, redactor: Arc<dyn AuditRedactor>) -> Self {
261        self.audit_redactor = Some(redactor);
262        self
263    }
264
265    /// Install the derived non-PII tenant attribution label. Runtime
266    /// records [`crate::Episode::RunAttributed`] at run entry when set.
267    /// Caller must pre-derive a non-PII label (e.g. via
268    /// [`crate::principal_hash`]) — the value is recorded into
269    /// episodic memory verbatim.
270    pub fn tenant_label(mut self, label: String) -> Self {
271        self.tenant_label = Some(label);
272        self
273    }
274
275    /// Consume the builder and produce an [`AgentContext`].
276    ///
277    /// Returns [`AgentContextBuilderError::MissingField`] when any
278    /// required field was not set.
279    pub fn build(self) -> Result<AgentContext, AgentContextBuilderError> {
280        let audit_redactor = self.audit_redactor.clone();
281        let tenant_label = self.tenant_label.clone();
282        let ctx = AgentContext::new(
283            self.llm
284                .ok_or(AgentContextBuilderError::MissingField("llm"))?,
285            self.short_term
286                .ok_or(AgentContextBuilderError::MissingField("short_term"))?,
287            self.long_term
288                .ok_or(AgentContextBuilderError::MissingField("long_term"))?,
289            self.episodic
290                .ok_or(AgentContextBuilderError::MissingField("episodic"))?,
291            self.pubsub
292                .ok_or(AgentContextBuilderError::MissingField("pubsub"))?,
293            self.kv
294                .ok_or(AgentContextBuilderError::MissingField("kv"))?,
295            self.request_reply
296                .ok_or(AgentContextBuilderError::MissingField("request_reply"))?,
297            self.jobs
298                .ok_or(AgentContextBuilderError::MissingField("jobs"))?,
299            self.tools
300                .ok_or(AgentContextBuilderError::MissingField("tools"))?,
301            self.run_id.unwrap_or_default(),
302            self.cancel.unwrap_or_default(),
303            self.agent_name
304                .ok_or(AgentContextBuilderError::MissingField("agent_name"))?,
305        );
306        Ok(AgentContext {
307            audit_redactor,
308            tenant_label,
309            ..ctx
310        })
311    }
312}
313
314impl AgentContext {
315    /// Fluent builder. See [`AgentContextBuilder`].
316    pub fn builder() -> AgentContextBuilder {
317        AgentContextBuilder::default()
318    }
319
320    /// Construct an `AgentContext` with all required fields. `progress` defaults to `None`.
321    #[allow(clippy::too_many_arguments)]
322    pub fn new(
323        llm: Arc<dyn LlmClient>,
324        short_term: Arc<dyn ShortTermMemory>,
325        long_term: Arc<dyn LongTermMemory>,
326        episodic: Arc<dyn EpisodicMemory>,
327        pubsub: Arc<dyn Pubsub>,
328        kv: Arc<dyn KvStore>,
329        request_reply: Arc<dyn RequestReply>,
330        jobs: Arc<dyn JobQueue>,
331        tools: Arc<dyn ToolInvoker>,
332        run_id: RunId,
333        cancel: CancellationToken,
334        agent_name: impl Into<String>,
335    ) -> Self {
336        Self {
337            llm,
338            short_term,
339            long_term,
340            episodic,
341            pubsub,
342            kv,
343            request_reply,
344            jobs,
345            tools,
346            run_id,
347            cancel,
348            agent_name: agent_name.into(),
349            progress: None,
350            audit_redactor: None,
351            tenant_label: None,
352            parent_anchor: None,
353        }
354    }
355
356    /// Replace the LLM client, returning a new context with all other fields cloned.
357    pub fn with_llm(self, llm: Arc<dyn LlmClient>) -> Self {
358        Self { llm, ..self }
359    }
360
361    /// Replace the tool invoker, returning a new context with all other fields cloned.
362    pub fn with_tools(self, tools: Arc<dyn ToolInvoker>) -> Self {
363        Self { tools, ..self }
364    }
365
366    /// Replace the episodic store, returning a new context with all other
367    /// fields preserved (e.g. to inject a per-run scoped or durable backend).
368    pub fn with_episodic(self, episodic: Arc<dyn EpisodicMemory>) -> Self {
369        Self { episodic, ..self }
370    }
371
372    /// Replace the short-term memory store, returning a new context with all
373    /// other fields preserved (e.g. to inject a thread-isolated backend).
374    pub fn with_short_term(self, short_term: Arc<dyn ShortTermMemory>) -> Self {
375        Self { short_term, ..self }
376    }
377
378    /// Replace the long-term memory store, returning a new context with all
379    /// other fields preserved (e.g. to swap vector backends per tenant).
380    pub fn with_long_term(self, long_term: Arc<dyn LongTermMemory>) -> Self {
381        Self { long_term, ..self }
382    }
383
384    /// Replace the run id, returning a new context with all other fields
385    /// preserved (e.g. to resume a run or stitch episodes into a known chain).
386    pub fn with_run_id(self, run_id: RunId) -> Self {
387        Self { run_id, ..self }
388    }
389
390    /// Install the audit redactor for PII-flagged tools, returning a new
391    /// context with all other fields cloned. When unset, dispatch falls
392    /// back to the fail-closed [`crate::opaque_digest`] for flagged
393    /// tools. Symmetric with [`AgentContextBuilder::audit_redactor`] for
394    /// callers that construct via [`AgentContext::new`].
395    pub fn with_audit_redactor(self, audit_redactor: Arc<dyn AuditRedactor>) -> Self {
396        Self {
397            audit_redactor: Some(audit_redactor),
398            ..self
399        }
400    }
401
402    /// Install the derived non-PII tenant attribution label. When set,
403    /// the runtime records [`crate::Episode::RunAttributed`] alongside
404    /// [`crate::Episode::Started`] at run entry. Caller must pre-derive
405    /// the label (e.g. via [`crate::principal_hash`]); raw principals
406    /// must never reach this surface. The label is audit metadata only
407    /// and is never appended to short-term memory or LLM-visible
408    /// messages.
409    pub fn with_tenant_label(self, label: String) -> Self {
410        Self {
411            tenant_label: Some(label),
412            ..self
413        }
414    }
415
416    /// Install the cross-hop provenance anchor (the caller's chain-entry
417    /// id). When set, the runtime records [`crate::Episode::RunOrigin`]
418    /// alongside [`crate::Episode::Started`] at run entry. The value is
419    /// recorded verbatim, is a caller-asserted unverified claim, and is
420    /// never appended to short-term memory or LLM-visible messages.
421    pub fn with_parent_anchor(self, anchor: String) -> Self {
422        Self {
423            parent_anchor: Some(anchor),
424            ..self
425        }
426    }
427
428    /// Publish `payload` on `subject`, threading this run's id into the
429    /// [`crate::bus::CAUSATION_HEADER`] so receivers can attribute causation,
430    /// and record an [`crate::Episode::BusPublish`]. The publisher identity is
431    /// the recording run, so the episode itself needs no extra field.
432    ///
433    /// Each dotted `subject` segment is validated via
434    /// [`crate::bus::validate_subject_token`] before publishing (CWE-74 subject
435    /// injection guard); a forbidden segment returns `BusError::Invalid` and
436    /// nothing is published. Episode recording is best-effort and logged on
437    /// failure — the message is already on the wire at that point.
438    pub async fn publish(
439        &self,
440        subject: &str,
441        payload: bytes::Bytes,
442    ) -> Result<(), crate::error::BusError> {
443        for segment in subject.split('.') {
444            crate::bus::validate_subject_token(segment)?;
445        }
446        let mut headers = crate::bus::Headers::new();
447        headers.insert(
448            crate::bus::CAUSATION_HEADER.to_string(),
449            self.run_id.to_string(),
450        );
451        self.pubsub.publish(subject, payload, headers).await?;
452        if let Err(error) = self
453            .episodic
454            .record(
455                self.run_id,
456                crate::memory::Episode::BusPublish {
457                    subject: subject.to_string(),
458                },
459            )
460            .await
461        {
462            tracing::warn!(%error, run_id = %self.run_id, subject, "failed to record BusPublish episode");
463        }
464        Ok(())
465    }
466
467    /// Record receipt of a bus `msg` on `subject`: always records a
468    /// [`crate::Episode::BusReceive`], and additionally records a
469    /// [`crate::Episode::BusCausalLink`] when the message carries a
470    /// [`crate::bus::CAUSATION_HEADER`] identifying the publishing run.
471    ///
472    /// Episode recording is best-effort; failures are logged and not
473    /// propagated — the message has already been delivered at call time.
474    pub async fn record_received(&self, subject: &str, msg: &crate::bus::Msg) {
475        if let Err(error) = self
476            .episodic
477            .record(
478                self.run_id,
479                crate::memory::Episode::BusReceive {
480                    subject: subject.to_string(),
481                },
482            )
483            .await
484        {
485            tracing::warn!(%error, run_id = %self.run_id, subject, "failed to record BusReceive episode");
486        }
487        if let Some(caused_by_run) = msg.headers.get(crate::bus::CAUSATION_HEADER) {
488            // The header is publisher-supplied and untrusted. A well-formed
489            // value is the canonical `RunId` string `ctx.publish` stamps; a
490            // non-ULID is malformed (or injected) and dropped — attribution is
491            // best-effort metadata, mirroring the nats-jobs claim path.
492            if ulid::Ulid::from_string(caused_by_run).is_err() {
493                tracing::warn!(
494                    run_id = %self.run_id,
495                    subject,
496                    "dropping malformed (non-ULID) causation header; no BusCausalLink recorded"
497                );
498            } else if let Err(error) = self
499                .episodic
500                .record(
501                    self.run_id,
502                    crate::memory::Episode::BusCausalLink {
503                        subject: subject.to_string(),
504                        caused_by_run: caused_by_run.clone(),
505                    },
506                )
507                .await
508            {
509                tracing::warn!(%error, run_id = %self.run_id, subject, "failed to record BusCausalLink episode");
510            }
511        }
512    }
513
514    /// Enqueue `job` on `queue`, stamping this run as the causer
515    /// (`job.causation_run_id`) so a worker that claims it can record a
516    /// causation link via [`Self::record_claimed`]. Each dotted `queue`
517    /// segment is validated ([`crate::bus::validate_subject_token`], CWE-74).
518    pub async fn enqueue(
519        &self,
520        queue: &str,
521        mut job: crate::bus::Job,
522    ) -> Result<crate::ids::JobId, crate::error::BusError> {
523        for segment in queue.split('.') {
524            crate::bus::validate_subject_token(segment)?;
525        }
526        job.causation_run_id = Some(self.run_id);
527        self.jobs.enqueue(queue, job).await
528    }
529
530    /// Record receipt of a claimed `job` from `queue`: always records a
531    /// [`crate::Episode::BusReceive`], and additionally records a
532    /// [`crate::Episode::BusCausalLink`] when the job carries a causing run.
533    ///
534    /// Episode recording is best-effort; failures are logged and not
535    /// propagated — the job has already been claimed at call time.
536    pub async fn record_claimed(&self, queue: &str, job: &crate::bus::ClaimedJob) {
537        if let Err(error) = self
538            .episodic
539            .record(
540                self.run_id,
541                crate::memory::Episode::BusReceive {
542                    subject: queue.to_string(),
543                },
544            )
545            .await
546        {
547            tracing::warn!(%error, run_id = %self.run_id, queue, "failed to record BusReceive episode");
548        }
549        if let Some(caused_by_run) = job.causation_run_id {
550            if let Err(error) = self
551                .episodic
552                .record(
553                    self.run_id,
554                    crate::memory::Episode::BusCausalLink {
555                        subject: queue.to_string(),
556                        caused_by_run: caused_by_run.to_string(),
557                    },
558                )
559                .await
560            {
561                tracing::warn!(%error, run_id = %self.run_id, queue, "failed to record BusCausalLink episode");
562            }
563        }
564    }
565
566    /// CAS-write `value` at `key` in `bucket` and, on success, stamp this run as
567    /// the value's causer ([`crate::bus::KvStore::put_causer`]) so a later reader
568    /// can attribute the write via [`Self::record_kv_read`]. A CAS conflict
569    /// (e.g. dedup redelivery) leaves any existing causer intact —
570    /// first-writer-wins. The causer write is best-effort (logged, not
571    /// propagated). Returns the value revision.
572    pub async fn kv_cas_caused_by(
573        &self,
574        bucket: &str,
575        key: &str,
576        value: bytes::Bytes,
577        expected: Option<crate::bus::Revision>,
578    ) -> Result<crate::bus::Revision, crate::error::BusError> {
579        let revision = self.kv.cas(bucket, key, value, expected).await?;
580        if let Err(error) = self.kv.put_causer(bucket, key, self.run_id).await {
581            tracing::warn!(%error, run_id = %self.run_id, bucket, key, "failed to write KV causer");
582        }
583        Ok(revision)
584    }
585
586    /// Record reading `key` from `bucket`: always a
587    /// [`crate::Episode::BusReceive`], plus a [`crate::Episode::BusCausalLink`]
588    /// to the run that wrote the value when one was stamped (via
589    /// [`Self::kv_cas_caused_by`]). Best-effort; failures are logged, not
590    /// propagated.
591    pub async fn record_kv_read(&self, bucket: &str, key: &str) {
592        if let Err(error) = self
593            .episodic
594            .record(
595                self.run_id,
596                crate::memory::Episode::BusReceive {
597                    subject: bucket.to_string(),
598                },
599            )
600            .await
601        {
602            tracing::warn!(%error, run_id = %self.run_id, bucket, key, "failed to record BusReceive episode");
603        }
604        match self.kv.causer_of(bucket, key).await {
605            Ok(Some(caused_by_run)) => {
606                if let Err(error) = self
607                    .episodic
608                    .record(
609                        self.run_id,
610                        crate::memory::Episode::BusCausalLink {
611                            subject: bucket.to_string(),
612                            caused_by_run: caused_by_run.to_string(),
613                        },
614                    )
615                    .await
616                {
617                    tracing::warn!(%error, run_id = %self.run_id, bucket, key, "failed to record BusCausalLink episode");
618                }
619            }
620            Ok(None) => {}
621            Err(error) => {
622                tracing::warn!(%error, run_id = %self.run_id, bucket, key, "failed to read KV causer")
623            }
624        }
625    }
626
627    /// Read the derived non-PII tenant attribution label installed via
628    /// [`Self::with_tenant_label`] or [`AgentContextBuilder::tenant_label`].
629    /// Returns `None` when the context has not been bound to a verified
630    /// caller (e.g. an in-process supervisor run that elides the
631    /// inbound-MCP tenant-binding hop). External crates use this to
632    /// route per-tenant budget governance without touching the
633    /// pub(crate) field directly.
634    #[must_use]
635    pub fn tenant_label(&self) -> Option<&str> {
636        self.tenant_label.as_deref()
637    }
638
639    /// Spawn a child context for a sub-run. Clones every `Arc<dyn …>`
640    /// handle, mints a fresh [`RunId`], sets `agent_name`, and inherits
641    /// the parent's cancellation token (cancelling the parent cancels
642    /// the child, but the child can also be cancelled independently).
643    ///
644    /// Used by composite agents (`klieo-flows`'s `SequentialAgent`,
645    /// `ParallelAgent`, etc.) to build per-leg contexts without manual
646    /// struct-spread boilerplate.
647    pub fn child(&self, agent_name: impl Into<String>) -> Self {
648        Self {
649            llm: self.llm.clone(),
650            short_term: self.short_term.clone(),
651            long_term: self.long_term.clone(),
652            episodic: self.episodic.clone(),
653            pubsub: self.pubsub.clone(),
654            kv: self.kv.clone(),
655            request_reply: self.request_reply.clone(),
656            jobs: self.jobs.clone(),
657            tools: self.tools.clone(),
658            run_id: RunId::new(),
659            cancel: self.cancel.child_token(),
660            agent_name: agent_name.into(),
661            progress: self.progress.clone(),
662            audit_redactor: self.audit_redactor.clone(),
663            tenant_label: self.tenant_label.clone(),
664            parent_anchor: self.parent_anchor.clone(),
665        }
666    }
667}
668
669/// One agent — a typed function from `Input` to `Output` plus prompt
670/// configuration.
671#[async_trait]
672pub trait Agent: Send + Sync {
673    /// Input payload type.
674    type Input: DeserializeOwned + Send + 'static;
675    /// Output payload type.
676    type Output: Serialize + Send + 'static;
677    /// Domain-specific error type. Wrap `crate::Error` if you don't need
678    /// a custom one.
679    type Error: std::error::Error + Send + Sync + 'static;
680
681    /// Stable agent name (used in spans + episodic events).
682    fn name(&self) -> &str;
683
684    /// System prompt prepended to the conversation.
685    fn system_prompt(&self) -> &str;
686
687    /// Tool catalogue this agent advertises to the LLM.
688    fn tools(&self) -> &[ToolDef];
689
690    /// Run one turn. Runtime supplies `ctx`; agent owns the per-call shape.
691    ///
692    /// ```
693    /// # tokio_test::block_on(async {
694    /// use async_trait::async_trait;
695    /// use klieo_core::{Agent, AgentContext, ToolDef};
696    /// struct Echo;
697    /// #[async_trait]
698    /// impl Agent for Echo {
699    ///     type Input = String;
700    ///     type Output = String;
701    ///     type Error = std::io::Error;
702    ///     fn name(&self) -> &str { "echo" }
703    ///     fn system_prompt(&self) -> &str { "" }
704    ///     fn tools(&self) -> &[ToolDef] { &[] }
705    ///     async fn run(&self, _ctx: AgentContext, input: String) -> Result<String, Self::Error> {
706    ///         Ok(input)
707    ///     }
708    /// }
709    /// let agent = Echo;
710    /// assert_eq!(agent.name(), "echo");
711    /// # });
712    /// ```
713    async fn run(&self, ctx: AgentContext, input: Self::Input)
714        -> Result<Self::Output, Self::Error>;
715}
716
717/// Canonical [`Agent`] implementation for the `String → String` case.
718///
719/// Wraps the boilerplate every example repeats: append the user
720/// message to short-term memory, delegate to
721/// [`crate::runtime::run_steps`] with the supplied system prompt.
722///
723/// Custom-typed agents (non-`String` input or output, alternative
724/// turn shapes) still implement `Agent` by hand; `SimpleAgent` is
725/// shortcut, not replacement.
726///
727/// ```
728/// # tokio_test::block_on(async {
729/// use klieo_core::{Agent, SimpleAgent};
730/// let agent = SimpleAgent::new("hello", "Be brief.", vec![]);
731/// assert_eq!(agent.name(), "hello");
732/// assert_eq!(agent.system_prompt(), "Be brief.");
733/// assert!(agent.tools().is_empty());
734/// # });
735/// ```
736pub struct SimpleAgent {
737    name: String,
738    system_prompt: String,
739    catalogue: Vec<crate::llm::ToolDef>,
740    run_options: crate::runtime::RunOptions,
741}
742
743impl SimpleAgent {
744    /// Build a `SimpleAgent` with the supplied name, system prompt,
745    /// and tool catalogue. Uses [`crate::runtime::RunOptions::default`]
746    /// — override via [`SimpleAgent::with_run_options`].
747    pub fn new(
748        name: impl Into<String>,
749        system_prompt: impl Into<String>,
750        catalogue: Vec<crate::llm::ToolDef>,
751    ) -> Self {
752        Self {
753            name: name.into(),
754            system_prompt: system_prompt.into(),
755            catalogue,
756            run_options: crate::runtime::RunOptions::default(),
757        }
758    }
759
760    /// Override the [`crate::runtime::RunOptions`] passed to
761    /// [`crate::runtime::run_steps`].
762    pub fn with_run_options(mut self, options: crate::runtime::RunOptions) -> Self {
763        self.run_options = options;
764        self
765    }
766
767    /// Install a review policy (the ADR-045 HITL suspend gate) on this agent's
768    /// run options. Composes with [`SimpleAgent::with_run_options`]: every other
769    /// option field keeps its configured value; only the review policy is
770    /// replaced.
771    pub fn with_review_policy(
772        mut self,
773        policy: std::sync::Arc<dyn crate::runtime::ReviewPolicy>,
774    ) -> Self {
775        self.run_options = self.run_options.with_review_policy(policy);
776        self
777    }
778}
779
780#[async_trait]
781impl Agent for SimpleAgent {
782    type Input = String;
783    type Output = String;
784    type Error = crate::error::Error;
785
786    fn name(&self) -> &str {
787        &self.name
788    }
789
790    fn system_prompt(&self) -> &str {
791        &self.system_prompt
792    }
793
794    fn tools(&self) -> &[ToolDef] {
795        &self.catalogue
796    }
797
798    async fn run(&self, ctx: AgentContext, input: String) -> Result<String, Self::Error> {
799        let thread = crate::ids::ThreadId::new(&self.name);
800        ctx.short_term
801            .append(
802                thread.clone(),
803                crate::llm::Message {
804                    role: crate::llm::Role::User,
805                    content: input,
806                    tool_calls: vec![],
807                    tool_call_id: None,
808                },
809            )
810            .await?;
811        crate::runtime::run_steps(&ctx, &self.system_prompt, thread, self.run_options.clone()).await
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818    use crate::test_utils::{fake_context, FakeLlmClient};
819
820    /// Compile-time check that AgentContext is Send + Sync + 'static.
821    fn _assert_ctx_send_sync_static() {
822        fn check<T: Send + Sync + 'static>() {}
823        check::<AgentContext>();
824    }
825
826    fn parent_ctx() -> AgentContext {
827        fake_context("parent")
828    }
829
830    struct PauseAlways;
831    #[async_trait]
832    impl crate::runtime::ReviewPolicy for PauseAlways {
833        async fn should_pause_for_approval(
834            &self,
835            _step: u32,
836            _message: &crate::llm::Message,
837        ) -> Result<Option<String>, crate::error::Error> {
838            Ok(Some("always".into()))
839        }
840    }
841
842    #[test]
843    fn child_mints_fresh_run_id() {
844        let p = parent_ctx();
845        let c = p.child("child-agent");
846        assert_ne!(c.run_id, p.run_id);
847    }
848
849    #[tokio::test]
850    async fn simple_agent_with_review_policy_folds_into_run_options() {
851        let plain = SimpleAgent::new("a", "p", vec![]);
852        assert!(
853            plain.run_options.review_policy.is_never(),
854            "a fresh SimpleAgent defaults to NeverReview"
855        );
856
857        let gated =
858            SimpleAgent::new("a", "p", vec![]).with_review_policy(std::sync::Arc::new(PauseAlways));
859
860        // Drive the installed policy, not just the `is_never()` sentinel: prove the
861        // PauseAlways the builder stored is the one the run loop would consult.
862        let msg = crate::llm::Message {
863            role: crate::llm::Role::Assistant,
864            content: "hi".into(),
865            tool_calls: vec![],
866            tool_call_id: None,
867        };
868        assert_eq!(
869            gated
870                .run_options
871                .review_policy
872                .should_pause_for_approval(1, &msg)
873                .await
874                .unwrap(),
875            Some("always".to_string()),
876            "with_review_policy must install the supplied policy on run_options, not merely flip is_never()"
877        );
878    }
879
880    #[test]
881    fn with_review_policy_preserves_other_run_options() {
882        let opts = crate::runtime::RunOptions::default().with_checkpoint_bucket("custom-bucket");
883        let agent = SimpleAgent::new("a", "p", vec![])
884            .with_run_options(opts)
885            .with_review_policy(std::sync::Arc::new(PauseAlways));
886
887        assert!(
888            !agent.run_options.review_policy.is_never(),
889            "the review policy is installed"
890        );
891        assert_eq!(
892            agent.run_options.checkpoint_kv_bucket.as_deref(),
893            Some("custom-bucket"),
894            "with_review_policy replaces only the policy — a field set via with_run_options survives"
895        );
896    }
897
898    #[test]
899    fn child_sets_new_agent_name() {
900        let p = parent_ctx();
901        let c = p.child("child-agent");
902        assert_eq!(c.agent_name, "child-agent");
903        assert_eq!(p.agent_name, "parent");
904    }
905
906    #[test]
907    fn child_inherits_cancellation_from_parent() {
908        let p = parent_ctx();
909        let c = p.child("child-agent");
910        assert!(!c.cancel.is_cancelled());
911        p.cancel.cancel();
912        assert!(
913            c.cancel.is_cancelled(),
914            "cancelling parent must propagate to child"
915        );
916    }
917
918    #[test]
919    fn child_shares_arc_handles_with_parent() {
920        let p = parent_ctx();
921        let c = p.child("child-agent");
922        assert!(Arc::ptr_eq(&p.llm, &c.llm));
923        assert!(Arc::ptr_eq(&p.short_term, &c.short_term));
924        assert!(Arc::ptr_eq(&p.long_term, &c.long_term));
925        assert!(Arc::ptr_eq(&p.episodic, &c.episodic));
926        assert!(Arc::ptr_eq(&p.pubsub, &c.pubsub));
927        assert!(Arc::ptr_eq(&p.kv, &c.kv));
928        assert!(Arc::ptr_eq(&p.request_reply, &c.request_reply));
929        assert!(Arc::ptr_eq(&p.jobs, &c.jobs));
930        assert!(Arc::ptr_eq(&p.tools, &c.tools));
931    }
932
933    #[test]
934    fn agent_event_variants_serialize_to_snake_case() {
935        let evt = AgentEvent::LlmCallCompleted {
936            tokens: 42,
937            latency_ms: 180,
938        };
939        let s = serde_json::to_string(&evt).unwrap();
940        assert!(s.contains(r#""kind":"llm_call_completed""#), "got: {s}");
941        assert!(s.contains(r#""tokens":42"#));
942        assert!(s.contains(r#""latency_ms":180"#));
943    }
944
945    #[test]
946    fn simple_agent_exposes_constructor_args() {
947        let cat = vec![ToolDef {
948            name: "echo".into(),
949            description: "e".into(),
950            json_schema: serde_json::json!({"type": "object"}),
951        }];
952        let agent = SimpleAgent::new("hello", "Be brief.", cat.clone());
953        assert_eq!(agent.name(), "hello");
954        assert_eq!(agent.system_prompt(), "Be brief.");
955        assert_eq!(agent.tools().len(), 1);
956        assert_eq!(agent.tools()[0].name, "echo");
957    }
958
959    #[test]
960    fn simple_agent_with_run_options_swaps_in_place() {
961        let opts = crate::runtime::RunOptions {
962            max_steps: 3,
963            ..crate::runtime::RunOptions::default()
964        };
965        let agent = SimpleAgent::new("a", "s", vec![]).with_run_options(opts);
966        assert_eq!(agent.run_options.max_steps, 3);
967    }
968
969    #[tokio::test]
970    async fn simple_agent_run_appends_user_then_returns_assistant_text() {
971        use crate::test_utils::FakeLlmStep;
972        let mut ctx = fake_context("simple-test");
973        ctx.llm =
974            Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
975        let short_term = ctx.short_term.clone();
976        let agent = SimpleAgent::new("simple-test", "be brief", vec![]);
977        let out = agent.run(ctx, "hi".into()).await.unwrap();
978        assert_eq!(out, "done");
979
980        let thread = crate::ids::ThreadId::new("simple-test");
981        let loaded = short_term.load(thread, 1000).await.unwrap();
982        assert!(
983            loaded
984                .iter()
985                .any(|m| matches!(m.role, crate::llm::Role::User) && m.content == "hi"),
986            "user message must be persisted to short-term before run_steps; got {loaded:?}",
987        );
988    }
989
990    #[test]
991    fn agent_event_tool_call_completed_serialises_name_and_ok() {
992        let evt = AgentEvent::ToolCallCompleted {
993            name: "echo".into(),
994            ok: true,
995        };
996        let s = serde_json::to_string(&evt).unwrap();
997        assert!(s.contains(r#""kind":"tool_call_completed""#));
998        assert!(s.contains(r#""name":"echo""#));
999        assert!(s.contains(r#""ok":true"#));
1000    }
1001
1002    #[test]
1003    fn builder_missing_required_field_returns_err() {
1004        let result = AgentContext::builder().agent_name("test").build();
1005        match result {
1006            Err(AgentContextBuilderError::MissingField(name)) => {
1007                assert!(!name.is_empty());
1008            }
1009            Ok(_) => panic!("expected error when required fields are absent"),
1010        }
1011    }
1012
1013    struct ConstRedactor;
1014
1015    impl AuditRedactor for ConstRedactor {
1016        fn redact(&self, _value: &serde_json::Value) -> serde_json::Value {
1017            serde_json::json!("[redacted]")
1018        }
1019    }
1020
1021    #[test]
1022    fn with_audit_redactor_installs_redactor_on_cloned_context() {
1023        let base = fake_context("redactor-test");
1024        assert!(base.audit_redactor.is_none());
1025        let ctx = base.with_audit_redactor(Arc::new(ConstRedactor));
1026        let redactor = ctx
1027            .audit_redactor
1028            .as_ref()
1029            .expect("redactor must be installed");
1030        assert_eq!(
1031            redactor.redact(&serde_json::json!({"pii": "secret"})),
1032            serde_json::json!("[redacted]")
1033        );
1034    }
1035
1036    #[test]
1037    fn tenant_label_defaults_to_none() {
1038        let ctx = fake_context("tenant-default");
1039        assert!(ctx.tenant_label.is_none());
1040    }
1041
1042    #[test]
1043    fn with_tenant_label_installs_label_on_cloned_context() {
1044        let base = fake_context("tenant-set");
1045        assert!(base.tenant_label.is_none());
1046        let ctx = base.with_tenant_label("hashed-tenant-abc".into());
1047        assert_eq!(ctx.tenant_label.as_deref(), Some("hashed-tenant-abc"));
1048    }
1049
1050    #[test]
1051    fn builder_threads_tenant_label_into_context() {
1052        let base = fake_context("tenant-builder");
1053        let ctx = AgentContext::builder()
1054            .llm(base.llm.clone())
1055            .short_term(base.short_term.clone())
1056            .long_term(base.long_term.clone())
1057            .episodic(base.episodic.clone())
1058            .pubsub(base.pubsub.clone())
1059            .kv(base.kv.clone())
1060            .request_reply(base.request_reply.clone())
1061            .jobs(base.jobs.clone())
1062            .tools(base.tools.clone())
1063            .agent_name("builder-tenant")
1064            .tenant_label("hashed-tenant-xyz".into())
1065            .build()
1066            .expect("all required fields are set");
1067        assert_eq!(ctx.tenant_label.as_deref(), Some("hashed-tenant-xyz"));
1068    }
1069
1070    #[test]
1071    fn child_inherits_tenant_label_from_parent() {
1072        let parent = fake_context("tenant-parent").with_tenant_label("hashed".into());
1073        let child = parent.child("tenant-child");
1074        assert_eq!(child.tenant_label.as_deref(), Some("hashed"));
1075    }
1076
1077    #[test]
1078    fn parent_anchor_defaults_to_none() {
1079        let ctx = fake_context("anchor-default");
1080        assert!(ctx.parent_anchor.is_none());
1081    }
1082
1083    #[test]
1084    fn with_parent_anchor_installs_anchor_on_cloned_context() {
1085        let base = fake_context("anchor-set");
1086        assert!(base.parent_anchor.is_none());
1087        let ctx = base.with_parent_anchor("sha256:abc123".into());
1088        assert_eq!(ctx.parent_anchor.as_deref(), Some("sha256:abc123"));
1089    }
1090
1091    #[test]
1092    fn child_inherits_parent_anchor_from_parent() {
1093        let parent = fake_context("anchor-parent").with_parent_anchor("sha256:abc123".into());
1094        let child = parent.child("anchor-child");
1095        assert_eq!(child.parent_anchor.as_deref(), Some("sha256:abc123"));
1096    }
1097
1098    /// Minimal pubsub that records every published message for test inspection.
1099    struct CapturingPubsub {
1100        published: std::sync::Mutex<Vec<crate::bus::Msg>>,
1101    }
1102
1103    impl CapturingPubsub {
1104        fn new() -> Self {
1105            Self {
1106                published: std::sync::Mutex::new(Vec::new()),
1107            }
1108        }
1109
1110        fn messages(&self) -> Vec<crate::bus::Msg> {
1111            self.published
1112                .lock()
1113                .unwrap()
1114                .iter()
1115                .map(|m| crate::bus::Msg {
1116                    subject: m.subject.clone(),
1117                    payload: m.payload.clone(),
1118                    headers: m.headers.clone(),
1119                    ack: crate::bus::AckHandle::new(Box::new(NoopAckImpl)),
1120                })
1121                .collect()
1122        }
1123    }
1124
1125    struct NoopAckImpl;
1126
1127    #[async_trait]
1128    impl crate::bus::AckHandleImpl for NoopAckImpl {
1129        async fn ack(self: Box<Self>) -> Result<(), crate::error::BusError> {
1130            Ok(())
1131        }
1132        async fn nak(
1133            self: Box<Self>,
1134            _delay: std::time::Duration,
1135        ) -> Result<(), crate::error::BusError> {
1136            Ok(())
1137        }
1138        async fn term(self: Box<Self>) -> Result<(), crate::error::BusError> {
1139            Ok(())
1140        }
1141    }
1142
1143    #[async_trait]
1144    impl crate::bus::Pubsub for CapturingPubsub {
1145        async fn publish(
1146            &self,
1147            subject: &str,
1148            payload: bytes::Bytes,
1149            headers: crate::bus::Headers,
1150        ) -> Result<(), crate::error::BusError> {
1151            self.published.lock().unwrap().push(crate::bus::Msg {
1152                subject: subject.to_string(),
1153                payload,
1154                headers,
1155                ack: crate::bus::AckHandle::new(Box::new(NoopAckImpl)),
1156            });
1157            Ok(())
1158        }
1159
1160        async fn subscribe(
1161            &self,
1162            _subject: &str,
1163            _durable: crate::ids::DurableName,
1164        ) -> Result<crate::bus::MsgStream, crate::error::BusError> {
1165            Ok(Box::pin(tokio_stream::empty()))
1166        }
1167    }
1168
1169    #[tokio::test]
1170    async fn publish_injects_causation_header_and_records_bus_publish() {
1171        use crate::bus::CAUSATION_HEADER;
1172
1173        let capturing = Arc::new(CapturingPubsub::new());
1174        let mut ctx = fake_context("planner");
1175        ctx.pubsub = capturing.clone();
1176        let run = ctx.run_id;
1177
1178        ctx.publish("jobs.research", bytes::Bytes::from_static(b"x"))
1179            .await
1180            .unwrap();
1181
1182        let messages = capturing.messages();
1183        assert_eq!(messages.len(), 1, "expected exactly one published message");
1184        let msg = &messages[0];
1185        assert_eq!(msg.subject, "jobs.research");
1186        assert_eq!(
1187            msg.headers.get(CAUSATION_HEADER).map(String::as_str),
1188            Some(run.to_string().as_str()),
1189            "causation header must carry the publisher's run id"
1190        );
1191
1192        let episodes = ctx.episodic.replay(run).await.unwrap();
1193        assert!(
1194            episodes
1195                .iter()
1196                .any(|e| matches!(e, crate::Episode::BusPublish { subject } if subject == "jobs.research")),
1197            "expected BusPublish episode for jobs.research; got {episodes:?}"
1198        );
1199    }
1200
1201    #[tokio::test]
1202    async fn publish_rejects_wildcard_subject_segment() {
1203        let capturing = Arc::new(CapturingPubsub::new());
1204        let mut ctx = fake_context("planner");
1205        ctx.pubsub = capturing.clone();
1206
1207        let result = ctx.publish("jobs.>", bytes::Bytes::from_static(b"x")).await;
1208
1209        assert!(
1210            result.is_err(),
1211            "a wildcard subject segment must be rejected"
1212        );
1213        assert!(
1214            capturing.messages().is_empty(),
1215            "nothing is published when validation fails"
1216        );
1217    }
1218
1219    #[tokio::test]
1220    async fn record_received_links_to_publisher_run() {
1221        use crate::bus::{AckHandle, Headers, Msg, CAUSATION_HEADER};
1222
1223        let receiver = fake_context("synth");
1224        let recv_run = receiver.run_id;
1225        let pub_run = crate::ids::RunId::new();
1226
1227        let mut headers = Headers::new();
1228        headers.insert(CAUSATION_HEADER.to_string(), pub_run.to_string());
1229        let msg = Msg {
1230            subject: "jobs.research".into(),
1231            payload: bytes::Bytes::from_static(b"x"),
1232            headers,
1233            ack: AckHandle::new(Box::new(NoopAckImpl)),
1234        };
1235
1236        receiver.record_received("jobs.research", &msg).await;
1237
1238        let eps = receiver.episodic.replay(recv_run).await.unwrap();
1239        assert!(
1240            eps.iter().any(|e| matches!(e,
1241                crate::Episode::BusCausalLink { subject, caused_by_run }
1242                if subject == "jobs.research" && *caused_by_run == pub_run.to_string())),
1243            "records BusCausalLink at the publisher run; got {eps:?}"
1244        );
1245        assert!(
1246            eps.iter().any(|e| matches!(e, crate::Episode::BusReceive { subject } if subject == "jobs.research")),
1247            "still records BusReceive"
1248        );
1249    }
1250
1251    #[tokio::test]
1252    async fn record_received_with_malformed_causation_header_records_no_link() {
1253        use crate::bus::{AckHandle, Headers, Msg, CAUSATION_HEADER};
1254
1255        let receiver = fake_context("synth");
1256        let recv_run = receiver.run_id;
1257
1258        let mut headers = Headers::new();
1259        headers.insert(CAUSATION_HEADER.to_string(), "not-a-ulid".to_string());
1260        let msg = Msg {
1261            subject: "jobs.research".into(),
1262            payload: bytes::Bytes::from_static(b"x"),
1263            headers,
1264            ack: AckHandle::new(Box::new(NoopAckImpl)),
1265        };
1266
1267        receiver.record_received("jobs.research", &msg).await;
1268
1269        let eps = receiver.episodic.replay(recv_run).await.unwrap();
1270        assert!(
1271            !eps.iter()
1272                .any(|e| matches!(e, crate::Episode::BusCausalLink { .. })),
1273            "malformed (non-ULID) header -> no link; got {eps:?}"
1274        );
1275        assert!(
1276            eps.iter()
1277                .any(|e| matches!(e, crate::Episode::BusReceive { subject } if subject == "jobs.research")),
1278            "BusReceive still recorded unconditionally"
1279        );
1280    }
1281
1282    #[tokio::test]
1283    async fn record_received_without_header_records_only_receive() {
1284        use crate::bus::{AckHandle, Headers, Msg};
1285
1286        let receiver = fake_context("synth");
1287        let recv_run = receiver.run_id;
1288        let msg = Msg {
1289            subject: "jobs.research".into(),
1290            payload: bytes::Bytes::new(),
1291            headers: Headers::new(),
1292            ack: AckHandle::new(Box::new(NoopAckImpl)),
1293        };
1294
1295        receiver.record_received("jobs.research", &msg).await;
1296
1297        let eps = receiver.episodic.replay(recv_run).await.unwrap();
1298        assert!(
1299            !eps.iter()
1300                .any(|e| matches!(e, crate::Episode::BusCausalLink { .. })),
1301            "no header -> no link"
1302        );
1303        assert!(
1304            eps.iter()
1305                .any(|e| matches!(e, crate::Episode::BusReceive { .. })),
1306            "receive still recorded"
1307        );
1308    }
1309
1310    #[test]
1311    fn builder_produces_valid_context_when_all_fields_set() {
1312        let base = fake_context("base");
1313        let ctx = AgentContext::builder()
1314            .llm(base.llm.clone())
1315            .short_term(base.short_term.clone())
1316            .long_term(base.long_term.clone())
1317            .episodic(base.episodic.clone())
1318            .pubsub(base.pubsub.clone())
1319            .kv(base.kv.clone())
1320            .request_reply(base.request_reply.clone())
1321            .jobs(base.jobs.clone())
1322            .tools(base.tools.clone())
1323            .agent_name("builder-test")
1324            .build()
1325            .expect("all required fields are set");
1326        assert_eq!(ctx.agent_name, "builder-test");
1327    }
1328
1329    /// Minimal JobQueue that records every enqueued job for test inspection.
1330    struct CapturingJobQueue {
1331        enqueued: std::sync::Mutex<Vec<(String, crate::bus::Job)>>,
1332        counter: std::sync::atomic::AtomicU32,
1333    }
1334
1335    impl CapturingJobQueue {
1336        fn new() -> Self {
1337            Self {
1338                enqueued: std::sync::Mutex::new(Vec::new()),
1339                counter: std::sync::atomic::AtomicU32::new(0),
1340            }
1341        }
1342
1343        fn jobs(&self) -> Vec<(String, crate::bus::Job)> {
1344            self.enqueued.lock().unwrap().clone()
1345        }
1346    }
1347
1348    #[async_trait]
1349    impl crate::bus::JobQueue for CapturingJobQueue {
1350        async fn enqueue(
1351            &self,
1352            queue: &str,
1353            job: crate::bus::Job,
1354        ) -> Result<crate::ids::JobId, crate::error::BusError> {
1355            let n = self
1356                .counter
1357                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1358            self.enqueued.lock().unwrap().push((queue.to_string(), job));
1359            Ok(crate::ids::JobId(format!("test-{n}")))
1360        }
1361
1362        async fn claim(
1363            &self,
1364            _queue: &str,
1365            _worker_id: &str,
1366            _lease_ttl: std::time::Duration,
1367        ) -> Result<Option<crate::bus::ClaimedJob>, crate::error::BusError> {
1368            Ok(None)
1369        }
1370    }
1371
1372    struct NoopLeaseImpl;
1373    #[async_trait]
1374    impl crate::bus::LeaseImpl for NoopLeaseImpl {
1375        async fn heartbeat(&self) -> Result<(), crate::error::BusError> {
1376            Ok(())
1377        }
1378    }
1379
1380    struct NoopClaimImpl;
1381    #[async_trait]
1382    impl crate::bus::ClaimHandleImpl for NoopClaimImpl {
1383        async fn ack(self: Box<Self>) -> Result<(), crate::error::BusError> {
1384            Ok(())
1385        }
1386        async fn nak(
1387            self: Box<Self>,
1388            _delay: std::time::Duration,
1389        ) -> Result<(), crate::error::BusError> {
1390            Ok(())
1391        }
1392        async fn dead_letter(self: Box<Self>, _reason: &str) -> Result<(), crate::error::BusError> {
1393            Ok(())
1394        }
1395    }
1396
1397    fn noop_claimed_job(causation: Option<crate::ids::RunId>) -> crate::bus::ClaimedJob {
1398        crate::bus::ClaimedJob::new(
1399            crate::ids::JobId("test-0".into()),
1400            bytes::Bytes::from_static(b"payload"),
1401            crate::bus::Lease::new(Box::new(NoopLeaseImpl)),
1402            crate::bus::ClaimHandle::new(Box::new(NoopClaimImpl)),
1403        )
1404        .with_causation(causation)
1405    }
1406
1407    #[tokio::test]
1408    async fn enqueue_sets_causation_to_this_run() {
1409        let capturing = Arc::new(CapturingJobQueue::new());
1410        let mut ctx = fake_context("enqueuer");
1411        ctx.jobs = capturing.clone();
1412        let run = ctx.run_id;
1413
1414        let job = crate::bus::Job::new(bytes::Bytes::from_static(b"work"));
1415        ctx.enqueue("work.items", job).await.unwrap();
1416
1417        let jobs = capturing.jobs();
1418        assert_eq!(jobs.len(), 1, "expected exactly one enqueued job");
1419        let (queue, enqueued_job) = &jobs[0];
1420        assert_eq!(queue, "work.items");
1421        assert_eq!(
1422            enqueued_job.causation_run_id,
1423            Some(run),
1424            "enqueue must stamp this run's id as causation_run_id"
1425        );
1426    }
1427
1428    #[tokio::test]
1429    async fn enqueue_rejects_wildcard_queue_segment() {
1430        let capturing = Arc::new(CapturingJobQueue::new());
1431        let mut ctx = fake_context("enqueuer");
1432        ctx.jobs = capturing.clone();
1433
1434        let job = crate::bus::Job::new(bytes::Bytes::from_static(b"work"));
1435        let result = ctx.enqueue("work.>", job).await;
1436
1437        assert!(result.is_err(), "a wildcard queue segment must be rejected");
1438        assert!(
1439            capturing.jobs().is_empty(),
1440            "nothing is enqueued when validation fails"
1441        );
1442    }
1443
1444    #[tokio::test]
1445    async fn record_claimed_links_to_causing_run() {
1446        let ctx = fake_context("worker");
1447        let worker_run = ctx.run_id;
1448        let causing_run = crate::ids::RunId::new();
1449
1450        let claimed = noop_claimed_job(Some(causing_run));
1451        ctx.record_claimed("work.items", &claimed).await;
1452
1453        let eps = ctx.episodic.replay(worker_run).await.unwrap();
1454        assert!(
1455            eps.iter().any(|e| matches!(e,
1456                crate::Episode::BusCausalLink { subject, caused_by_run }
1457                if subject == "work.items" && *caused_by_run == causing_run.to_string()
1458            )),
1459            "record_claimed must emit BusCausalLink for the causing run; got {eps:?}"
1460        );
1461        assert!(
1462            eps.iter().any(
1463                |e| matches!(e, crate::Episode::BusReceive { subject } if subject == "work.items")
1464            ),
1465            "record_claimed must also emit BusReceive; got {eps:?}"
1466        );
1467    }
1468
1469    #[tokio::test]
1470    async fn record_claimed_without_causation_records_only_receive() {
1471        let ctx = fake_context("worker-no-cause");
1472        let worker_run = ctx.run_id;
1473
1474        let claimed = noop_claimed_job(None);
1475        ctx.record_claimed("work.items", &claimed).await;
1476
1477        let eps = ctx.episodic.replay(worker_run).await.unwrap();
1478        assert!(
1479            !eps.iter()
1480                .any(|e| matches!(e, crate::Episode::BusCausalLink { .. })),
1481            "no causation_run_id -> no BusCausalLink; got {eps:?}"
1482        );
1483        assert!(
1484            eps.iter()
1485                .any(|e| matches!(e, crate::Episode::BusReceive { .. })),
1486            "BusReceive must still be recorded; got {eps:?}"
1487        );
1488    }
1489
1490    #[tokio::test]
1491    async fn kv_cas_caused_by_then_record_kv_read_links_to_writer() {
1492        use crate::test_utils::FakeKvStore;
1493        let kv: Arc<dyn KvStore> = Arc::new(FakeKvStore::default());
1494
1495        let mut writer = fake_context("worker");
1496        writer.kv = kv.clone();
1497        let writer_run = writer.run_id;
1498        writer
1499            .kv_cas_caused_by("results", "k", bytes::Bytes::from_static(b"v"), None)
1500            .await
1501            .unwrap();
1502
1503        let mut reader = fake_context("synth");
1504        reader.kv = kv.clone();
1505        let reader_run = reader.run_id;
1506        reader.record_kv_read("results", "k").await;
1507
1508        let eps = reader.episodic.replay(reader_run).await.unwrap();
1509        assert!(
1510            eps.iter().any(
1511                |e| matches!(e, crate::Episode::BusReceive { subject } if subject == "results")
1512            ),
1513            "reader records BusReceive; got {eps:?}"
1514        );
1515        assert!(
1516            eps.iter().any(
1517                |e| matches!(e, crate::Episode::BusCausalLink { subject, caused_by_run }
1518                if subject == "results" && *caused_by_run == writer_run.to_string())
1519            ),
1520            "reader links to the writer run; got {eps:?}"
1521        );
1522    }
1523
1524    #[tokio::test]
1525    async fn record_kv_read_unstamped_records_receive_only() {
1526        use crate::test_utils::FakeKvStore;
1527        let mut reader = fake_context("synth-no-cause");
1528        reader.kv = Arc::new(FakeKvStore::default());
1529        let run = reader.run_id;
1530        reader
1531            .kv
1532            .put("results", "k", bytes::Bytes::from_static(b"v"))
1533            .await
1534            .unwrap();
1535        reader.record_kv_read("results", "k").await;
1536
1537        let eps = reader.episodic.replay(run).await.unwrap();
1538        assert!(
1539            eps.iter().any(
1540                |e| matches!(e, crate::Episode::BusReceive { subject } if subject == "results")
1541            ),
1542            "unstamped read still records BusReceive on the bucket; got {eps:?}"
1543        );
1544        assert!(
1545            !eps.iter()
1546                .any(|e| matches!(e, crate::Episode::BusCausalLink { .. })),
1547            "unstamped read -> no BusCausalLink; got {eps:?}"
1548        );
1549    }
1550
1551    #[test]
1552    fn with_episodic_replaces_episodic_store() {
1553        use crate::test_utils::InMemoryEpisodic;
1554        let base = fake_context("with-episodic-test");
1555        let stub: Arc<dyn crate::memory::EpisodicMemory> = Arc::new(InMemoryEpisodic::default());
1556        // Confirm the stub is pointer-distinct from the default installed by fake_context.
1557        assert!(
1558            !Arc::ptr_eq(&base.episodic, &stub,),
1559            "precondition: stub must be a different allocation"
1560        );
1561        let ctx = base.with_episodic(Arc::clone(&stub));
1562        assert!(
1563            Arc::ptr_eq(&ctx.episodic, &stub),
1564            "with_episodic must install the supplied store"
1565        );
1566    }
1567
1568    #[test]
1569    fn with_short_term_replaces_short_term_store() {
1570        use crate::test_utils::InMemoryShortTerm;
1571        let base = fake_context("with-short-term-test");
1572        let stub: Arc<dyn crate::memory::ShortTermMemory> = Arc::new(InMemoryShortTerm::default());
1573        assert!(
1574            !Arc::ptr_eq(&base.short_term, &stub),
1575            "precondition: stub must be a different allocation"
1576        );
1577        let ctx = base.with_short_term(Arc::clone(&stub));
1578        assert!(
1579            Arc::ptr_eq(&ctx.short_term, &stub),
1580            "with_short_term must install the supplied store"
1581        );
1582    }
1583
1584    #[test]
1585    fn with_long_term_replaces_long_term_store() {
1586        use crate::test_utils::InMemoryLongTerm;
1587        let base = fake_context("with-long-term-test");
1588        let stub: Arc<dyn crate::memory::LongTermMemory> = Arc::new(InMemoryLongTerm::default());
1589        assert!(
1590            !Arc::ptr_eq(&base.long_term, &stub),
1591            "precondition: stub must be a different allocation"
1592        );
1593        let ctx = base.with_long_term(Arc::clone(&stub));
1594        assert!(
1595            Arc::ptr_eq(&ctx.long_term, &stub),
1596            "with_long_term must install the supplied store"
1597        );
1598    }
1599
1600    #[test]
1601    fn with_run_id_installs_known_run_id() {
1602        let base = fake_context("with-run-id-test");
1603        let known_id = RunId::new();
1604        // Ensure we have a distinct id to install.
1605        assert_ne!(
1606            base.run_id, known_id,
1607            "precondition: RunId::new() produces distinct ids"
1608        );
1609        let ctx = base.with_run_id(known_id);
1610        assert_eq!(
1611            ctx.run_id, known_id,
1612            "with_run_id must install the given id"
1613        );
1614    }
1615}