use crate::bus::{JobQueue, KvStore, Pubsub, RequestReply};
use crate::ids::RunId;
use crate::llm::{LlmClient, ToolDef};
use crate::memory::{EpisodicMemory, LongTermMemory, ShortTermMemory};
use crate::tool::ToolInvoker;
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AgentEvent {
LlmCallStarted,
LlmCallCompleted {
tokens: u32,
latency_ms: u64,
},
ToolCallStarted {
name: String,
},
ToolCallCompleted {
name: String,
ok: bool,
},
Completed,
Failed {
reason: String,
},
}
#[derive(Clone)]
#[non_exhaustive]
pub struct AgentContext {
pub llm: Arc<dyn LlmClient>,
pub short_term: Arc<dyn ShortTermMemory>,
pub long_term: Arc<dyn LongTermMemory>,
pub episodic: Arc<dyn EpisodicMemory>,
pub pubsub: Arc<dyn Pubsub>,
pub kv: Arc<dyn KvStore>,
pub request_reply: Arc<dyn RequestReply>,
pub jobs: Arc<dyn JobQueue>,
pub tools: Arc<dyn ToolInvoker>,
pub run_id: RunId,
pub cancel: CancellationToken,
pub agent_name: String,
pub progress: Option<tokio::sync::broadcast::Sender<AgentEvent>>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AgentContextBuilderError {
#[error("required field missing: {0}")]
MissingField(&'static str),
}
#[derive(Default)]
pub struct AgentContextBuilder {
llm: Option<Arc<dyn LlmClient>>,
short_term: Option<Arc<dyn ShortTermMemory>>,
long_term: Option<Arc<dyn LongTermMemory>>,
episodic: Option<Arc<dyn EpisodicMemory>>,
pubsub: Option<Arc<dyn Pubsub>>,
kv: Option<Arc<dyn KvStore>>,
request_reply: Option<Arc<dyn RequestReply>>,
jobs: Option<Arc<dyn JobQueue>>,
tools: Option<Arc<dyn ToolInvoker>>,
run_id: Option<RunId>,
cancel: Option<CancellationToken>,
agent_name: Option<String>,
}
impl AgentContextBuilder {
pub fn llm(mut self, v: Arc<dyn LlmClient>) -> Self {
self.llm = Some(v);
self
}
pub fn short_term(mut self, v: Arc<dyn ShortTermMemory>) -> Self {
self.short_term = Some(v);
self
}
pub fn long_term(mut self, v: Arc<dyn LongTermMemory>) -> Self {
self.long_term = Some(v);
self
}
pub fn episodic(mut self, v: Arc<dyn EpisodicMemory>) -> Self {
self.episodic = Some(v);
self
}
pub fn pubsub(mut self, v: Arc<dyn Pubsub>) -> Self {
self.pubsub = Some(v);
self
}
pub fn kv(mut self, v: Arc<dyn KvStore>) -> Self {
self.kv = Some(v);
self
}
pub fn request_reply(mut self, v: Arc<dyn RequestReply>) -> Self {
self.request_reply = Some(v);
self
}
pub fn jobs(mut self, v: Arc<dyn JobQueue>) -> Self {
self.jobs = Some(v);
self
}
pub fn tools(mut self, v: Arc<dyn ToolInvoker>) -> Self {
self.tools = Some(v);
self
}
pub fn run_id(mut self, v: RunId) -> Self {
self.run_id = Some(v);
self
}
pub fn cancel(mut self, v: CancellationToken) -> Self {
self.cancel = Some(v);
self
}
pub fn agent_name(mut self, v: impl Into<String>) -> Self {
self.agent_name = Some(v.into());
self
}
pub fn build(self) -> Result<AgentContext, AgentContextBuilderError> {
Ok(AgentContext::new(
self.llm
.ok_or(AgentContextBuilderError::MissingField("llm"))?,
self.short_term
.ok_or(AgentContextBuilderError::MissingField("short_term"))?,
self.long_term
.ok_or(AgentContextBuilderError::MissingField("long_term"))?,
self.episodic
.ok_or(AgentContextBuilderError::MissingField("episodic"))?,
self.pubsub
.ok_or(AgentContextBuilderError::MissingField("pubsub"))?,
self.kv.ok_or(AgentContextBuilderError::MissingField("kv"))?,
self.request_reply
.ok_or(AgentContextBuilderError::MissingField("request_reply"))?,
self.jobs
.ok_or(AgentContextBuilderError::MissingField("jobs"))?,
self.tools
.ok_or(AgentContextBuilderError::MissingField("tools"))?,
self.run_id.unwrap_or_else(RunId::new),
self.cancel.unwrap_or_default(),
self.agent_name
.ok_or(AgentContextBuilderError::MissingField("agent_name"))?,
))
}
}
impl AgentContext {
pub fn builder() -> AgentContextBuilder {
AgentContextBuilder::default()
}
#[allow(clippy::too_many_arguments)]
pub fn new(
llm: Arc<dyn LlmClient>,
short_term: Arc<dyn ShortTermMemory>,
long_term: Arc<dyn LongTermMemory>,
episodic: Arc<dyn EpisodicMemory>,
pubsub: Arc<dyn Pubsub>,
kv: Arc<dyn KvStore>,
request_reply: Arc<dyn RequestReply>,
jobs: Arc<dyn JobQueue>,
tools: Arc<dyn ToolInvoker>,
run_id: RunId,
cancel: CancellationToken,
agent_name: impl Into<String>,
) -> Self {
Self {
llm,
short_term,
long_term,
episodic,
pubsub,
kv,
request_reply,
jobs,
tools,
run_id,
cancel,
agent_name: agent_name.into(),
progress: None,
}
}
pub fn with_llm(self, llm: Arc<dyn LlmClient>) -> Self {
Self { llm, ..self }
}
pub fn with_tools(self, tools: Arc<dyn ToolInvoker>) -> Self {
Self { tools, ..self }
}
pub fn child(&self, agent_name: impl Into<String>) -> Self {
Self {
llm: self.llm.clone(),
short_term: self.short_term.clone(),
long_term: self.long_term.clone(),
episodic: self.episodic.clone(),
pubsub: self.pubsub.clone(),
kv: self.kv.clone(),
request_reply: self.request_reply.clone(),
jobs: self.jobs.clone(),
tools: self.tools.clone(),
run_id: RunId::new(),
cancel: self.cancel.child_token(),
agent_name: agent_name.into(),
progress: self.progress.clone(),
}
}
}
#[async_trait]
pub trait Agent: Send + Sync {
type Input: DeserializeOwned + Send + 'static;
type Output: Serialize + Send + 'static;
type Error: std::error::Error + Send + Sync + 'static;
fn name(&self) -> &str;
fn system_prompt(&self) -> &str;
fn tools(&self) -> &[ToolDef];
async fn run(&self, ctx: AgentContext, input: Self::Input)
-> Result<Self::Output, Self::Error>;
}
pub struct SimpleAgent {
name: String,
system_prompt: String,
catalogue: Vec<crate::llm::ToolDef>,
run_options: crate::runtime::RunOptions,
}
impl SimpleAgent {
pub fn new(
name: impl Into<String>,
system_prompt: impl Into<String>,
catalogue: Vec<crate::llm::ToolDef>,
) -> Self {
Self {
name: name.into(),
system_prompt: system_prompt.into(),
catalogue,
run_options: crate::runtime::RunOptions::default(),
}
}
pub fn with_run_options(mut self, options: crate::runtime::RunOptions) -> Self {
self.run_options = options;
self
}
}
#[async_trait]
impl Agent for SimpleAgent {
type Input = String;
type Output = String;
type Error = crate::error::Error;
fn name(&self) -> &str {
&self.name
}
fn system_prompt(&self) -> &str {
&self.system_prompt
}
fn tools(&self) -> &[ToolDef] {
&self.catalogue
}
async fn run(&self, ctx: AgentContext, input: String) -> Result<String, Self::Error> {
let thread = crate::ids::ThreadId::new(&self.name);
ctx.short_term
.append(
thread.clone(),
crate::llm::Message {
role: crate::llm::Role::User,
content: input,
tool_calls: vec![],
tool_call_id: None,
},
)
.await?;
crate::runtime::run_steps(&ctx, &self.system_prompt, thread, self.run_options.clone()).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{fake_context, FakeLlmClient};
fn _assert_ctx_send_sync_static() {
fn check<T: Send + Sync + 'static>() {}
check::<AgentContext>();
}
fn parent_ctx() -> AgentContext {
fake_context("parent")
}
#[test]
fn child_mints_fresh_run_id() {
let p = parent_ctx();
let c = p.child("child-agent");
assert_ne!(c.run_id, p.run_id);
}
#[test]
fn child_sets_new_agent_name() {
let p = parent_ctx();
let c = p.child("child-agent");
assert_eq!(c.agent_name, "child-agent");
assert_eq!(p.agent_name, "parent");
}
#[test]
fn child_inherits_cancellation_from_parent() {
let p = parent_ctx();
let c = p.child("child-agent");
assert!(!c.cancel.is_cancelled());
p.cancel.cancel();
assert!(
c.cancel.is_cancelled(),
"cancelling parent must propagate to child"
);
}
#[test]
fn child_shares_arc_handles_with_parent() {
let p = parent_ctx();
let c = p.child("child-agent");
assert!(Arc::ptr_eq(&p.llm, &c.llm));
assert!(Arc::ptr_eq(&p.short_term, &c.short_term));
assert!(Arc::ptr_eq(&p.long_term, &c.long_term));
assert!(Arc::ptr_eq(&p.episodic, &c.episodic));
assert!(Arc::ptr_eq(&p.pubsub, &c.pubsub));
assert!(Arc::ptr_eq(&p.kv, &c.kv));
assert!(Arc::ptr_eq(&p.request_reply, &c.request_reply));
assert!(Arc::ptr_eq(&p.jobs, &c.jobs));
assert!(Arc::ptr_eq(&p.tools, &c.tools));
}
#[test]
fn agent_event_variants_serialize_to_snake_case() {
let evt = AgentEvent::LlmCallCompleted {
tokens: 42,
latency_ms: 180,
};
let s = serde_json::to_string(&evt).unwrap();
assert!(s.contains(r#""kind":"llm_call_completed""#), "got: {s}");
assert!(s.contains(r#""tokens":42"#));
assert!(s.contains(r#""latency_ms":180"#));
}
#[test]
fn simple_agent_exposes_constructor_args() {
let cat = vec![ToolDef {
name: "echo".into(),
description: "e".into(),
json_schema: serde_json::json!({"type": "object"}),
}];
let agent = SimpleAgent::new("hello", "Be brief.", cat.clone());
assert_eq!(agent.name(), "hello");
assert_eq!(agent.system_prompt(), "Be brief.");
assert_eq!(agent.tools().len(), 1);
assert_eq!(agent.tools()[0].name, "echo");
}
#[test]
fn simple_agent_with_run_options_swaps_in_place() {
let opts = crate::runtime::RunOptions {
max_steps: 3,
..crate::runtime::RunOptions::default()
};
let agent = SimpleAgent::new("a", "s", vec![]).with_run_options(opts);
assert_eq!(agent.run_options.max_steps, 3);
}
#[tokio::test]
async fn simple_agent_run_appends_user_then_returns_assistant_text() {
use crate::test_utils::FakeLlmStep;
let mut ctx = fake_context("simple-test");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
let short_term = ctx.short_term.clone();
let agent = SimpleAgent::new("simple-test", "be brief", vec![]);
let out = agent.run(ctx, "hi".into()).await.unwrap();
assert_eq!(out, "done");
let thread = crate::ids::ThreadId::new("simple-test");
let loaded = short_term.load(thread, 1000).await.unwrap();
assert!(
loaded
.iter()
.any(|m| matches!(m.role, crate::llm::Role::User) && m.content == "hi"),
"user message must be persisted to short-term before run_steps; got {loaded:?}",
);
}
#[test]
fn agent_event_tool_call_completed_serialises_name_and_ok() {
let evt = AgentEvent::ToolCallCompleted {
name: "echo".into(),
ok: true,
};
let s = serde_json::to_string(&evt).unwrap();
assert!(s.contains(r#""kind":"tool_call_completed""#));
assert!(s.contains(r#""name":"echo""#));
assert!(s.contains(r#""ok":true"#));
}
#[test]
fn builder_missing_required_field_returns_err() {
let result = AgentContext::builder().agent_name("test").build();
match result {
Err(AgentContextBuilderError::MissingField(name)) => {
assert!(!name.is_empty());
}
Ok(_) => panic!("expected error when required fields are absent"),
}
}
#[test]
fn builder_produces_valid_context_when_all_fields_set() {
let base = fake_context("base");
let ctx = AgentContext::builder()
.llm(base.llm.clone())
.short_term(base.short_term.clone())
.long_term(base.long_term.clone())
.episodic(base.episodic.clone())
.pubsub(base.pubsub.clone())
.kv(base.kv.clone())
.request_reply(base.request_reply.clone())
.jobs(base.jobs.clone())
.tools(base.tools.clone())
.agent_name("builder-test")
.build()
.expect("all required fields are set");
assert_eq!(ctx.agent_name, "builder-test");
}
}