1use 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#[derive(Clone, Debug, Serialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum AgentEvent {
26 LlmCallStarted,
28 LlmCallCompleted {
32 tokens: u32,
35 latency_ms: u64,
37 },
38 ToolCallStarted {
40 name: String,
42 },
43 ToolCallCompleted {
47 name: String,
49 ok: bool,
51 },
52 Completed,
54 Failed {
58 reason: String,
60 },
61 Suspended {
63 reason: String,
65 },
66}
67
68#[derive(Clone)]
71#[non_exhaustive]
72pub struct AgentContext {
73 pub llm: Arc<dyn LlmClient>,
75 pub short_term: Arc<dyn ShortTermMemory>,
77 pub long_term: Arc<dyn LongTermMemory>,
79 pub episodic: Arc<dyn EpisodicMemory>,
81 pub pubsub: Arc<dyn Pubsub>,
83 pub kv: Arc<dyn KvStore>,
85 pub request_reply: Arc<dyn RequestReply>,
87 pub jobs: Arc<dyn JobQueue>,
89 pub tools: Arc<dyn ToolInvoker>,
91 pub run_id: RunId,
93 pub cancel: CancellationToken,
95 pub agent_name: String,
98 pub progress: Option<tokio::sync::broadcast::Sender<AgentEvent>>,
107 pub(crate) audit_redactor: Option<Arc<dyn AuditRedactor>>,
114 pub(crate) tenant_label: Option<String>,
127 pub(crate) parent_anchor: Option<String>,
140}
141
142#[derive(Debug, thiserror::Error)]
144#[non_exhaustive]
145pub enum AgentContextBuilderError {
146 #[error("required field missing: {0}")]
148 MissingField(&'static str),
149}
150
151#[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 pub fn llm(mut self, v: Arc<dyn LlmClient>) -> Self {
187 self.llm = Some(v);
188 self
189 }
190
191 pub fn short_term(mut self, v: Arc<dyn ShortTermMemory>) -> Self {
193 self.short_term = Some(v);
194 self
195 }
196
197 pub fn long_term(mut self, v: Arc<dyn LongTermMemory>) -> Self {
199 self.long_term = Some(v);
200 self
201 }
202
203 pub fn episodic(mut self, v: Arc<dyn EpisodicMemory>) -> Self {
205 self.episodic = Some(v);
206 self
207 }
208
209 pub fn pubsub(mut self, v: Arc<dyn Pubsub>) -> Self {
211 self.pubsub = Some(v);
212 self
213 }
214
215 pub fn kv(mut self, v: Arc<dyn KvStore>) -> Self {
217 self.kv = Some(v);
218 self
219 }
220
221 pub fn request_reply(mut self, v: Arc<dyn RequestReply>) -> Self {
223 self.request_reply = Some(v);
224 self
225 }
226
227 pub fn jobs(mut self, v: Arc<dyn JobQueue>) -> Self {
229 self.jobs = Some(v);
230 self
231 }
232
233 pub fn tools(mut self, v: Arc<dyn ToolInvoker>) -> Self {
235 self.tools = Some(v);
236 self
237 }
238
239 pub fn run_id(mut self, v: RunId) -> Self {
241 self.run_id = Some(v);
242 self
243 }
244
245 pub fn cancel(mut self, v: CancellationToken) -> Self {
247 self.cancel = Some(v);
248 self
249 }
250
251 pub fn agent_name(mut self, v: impl Into<String>) -> Self {
253 self.agent_name = Some(v.into());
254 self
255 }
256
257 pub fn audit_redactor(mut self, redactor: Arc<dyn AuditRedactor>) -> Self {
261 self.audit_redactor = Some(redactor);
262 self
263 }
264
265 pub fn tenant_label(mut self, label: String) -> Self {
271 self.tenant_label = Some(label);
272 self
273 }
274
275 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 pub fn builder() -> AgentContextBuilder {
317 AgentContextBuilder::default()
318 }
319
320 #[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 pub fn with_llm(self, llm: Arc<dyn LlmClient>) -> Self {
358 Self { llm, ..self }
359 }
360
361 pub fn with_tools(self, tools: Arc<dyn ToolInvoker>) -> Self {
363 Self { tools, ..self }
364 }
365
366 pub fn with_episodic(self, episodic: Arc<dyn EpisodicMemory>) -> Self {
369 Self { episodic, ..self }
370 }
371
372 pub fn with_short_term(self, short_term: Arc<dyn ShortTermMemory>) -> Self {
375 Self { short_term, ..self }
376 }
377
378 pub fn with_long_term(self, long_term: Arc<dyn LongTermMemory>) -> Self {
381 Self { long_term, ..self }
382 }
383
384 pub fn with_run_id(self, run_id: RunId) -> Self {
387 Self { run_id, ..self }
388 }
389
390 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 pub fn with_tenant_label(self, label: String) -> Self {
410 Self {
411 tenant_label: Some(label),
412 ..self
413 }
414 }
415
416 pub fn with_parent_anchor(self, anchor: String) -> Self {
422 Self {
423 parent_anchor: Some(anchor),
424 ..self
425 }
426 }
427
428 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 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 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 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 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 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 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 #[must_use]
635 pub fn tenant_label(&self) -> Option<&str> {
636 self.tenant_label.as_deref()
637 }
638
639 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#[async_trait]
672pub trait Agent: Send + Sync {
673 type Input: DeserializeOwned + Send + 'static;
675 type Output: Serialize + Send + 'static;
677 type Error: std::error::Error + Send + Sync + 'static;
680
681 fn name(&self) -> &str;
683
684 fn system_prompt(&self) -> &str;
686
687 fn tools(&self) -> &[ToolDef];
689
690 async fn run(&self, ctx: AgentContext, input: Self::Input)
714 -> Result<Self::Output, Self::Error>;
715}
716
717pub 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 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 pub fn with_run_options(mut self, options: crate::runtime::RunOptions) -> Self {
763 self.run_options = options;
764 self
765 }
766
767 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 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 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 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 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 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 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}