mod dispatch;
mod guardrails;
mod options;
mod retry;
mod step;
mod streaming;
mod streaming_forward;
use crate::agent::{AgentContext, AgentEvent};
use crate::error::{Error, LlmError};
use crate::ids::ThreadId;
use crate::llm::{ChatRequest, Message, Role};
use crate::memory::Episode;
use std::time::Instant;
use tracing::{error, instrument};
use guardrails::{check_post_llm, check_pre_llm};
use retry::complete_with_retry;
use step::{record_and_dispatch, StepDisposition, StepOutcome};
pub(crate) fn emit(ctx: &AgentContext, event: AgentEvent) {
if let Some(tx) = &ctx.progress {
let _ = tx.send(event);
}
}
fn sanitised_reason(err: &Error) -> String {
match err {
Error::Llm(LlmError::RateLimit { .. }) => "llm rate-limited".into(),
Error::Llm(LlmError::Unauthorized) => "llm auth failed".into(),
Error::Llm(LlmError::Server(_)) => "llm server error".into(),
Error::Llm(LlmError::BadRequest(_)) => "llm bad request".into(),
Error::Llm(LlmError::Network(_)) => "llm network error".into(),
Error::Llm(LlmError::Timeout) => "llm timeout".into(),
Error::Llm(LlmError::Decoding(_)) => "llm decoding error".into(),
Error::Llm(LlmError::Unsupported(_)) => "llm unsupported capability".into(),
Error::Llm(LlmError::Cancelled) => "cancelled".into(),
Error::Cancelled => "cancelled".into(),
Error::MaxStepsExceeded { .. } => "max steps exceeded".into(),
Error::Bus(_) => "bus error".into(),
Error::Memory(_) => "memory error".into(),
Error::Tool(_) => "tool error".into(),
Error::Refused { .. } => "guardrail rejected".into(),
Error::Handoff { .. } => "guardrail handoff".into(),
_ => "internal error".into(),
}
}
pub use options::RunOptions;
pub use streaming::run_steps_streaming;
#[instrument(level = "debug", skip(ctx, system_prompt), fields(run_id = %ctx.run_id))]
pub async fn run_steps(
ctx: &AgentContext,
system_prompt: &str,
thread: ThreadId,
opts: RunOptions,
) -> Result<String, Error> {
ctx.episodic
.record(
ctx.run_id,
Episode::Started {
agent: ctx.agent_name.clone(),
},
)
.await?;
let mut step = 0u32;
loop {
if ctx.cancel.is_cancelled() {
error!("cancelled");
emit(
ctx,
AgentEvent::Failed {
reason: "cancelled".into(),
},
);
return Err(Error::Cancelled);
}
if step >= opts.max_steps {
emit(
ctx,
AgentEvent::Failed {
reason: format!("max steps exceeded ({})", opts.max_steps),
},
);
return Err(Error::MaxStepsExceeded {
steps: opts.max_steps,
});
}
step += 1;
let req = build_request(ctx, system_prompt, &thread, opts.max_history_tokens).await?;
check_pre_llm(&opts.guardrails, &req).await?;
let llm_started = Instant::now();
emit(ctx, AgentEvent::LlmCallStarted);
let resp = match complete_with_retry(ctx.llm.as_ref(), &ctx.cancel, req.clone()).await {
Ok(r) => r,
Err(e) => {
let latency_ms = llm_started.elapsed().as_millis() as u64;
emit(
ctx,
AgentEvent::LlmCallCompleted {
tokens: 0,
latency_ms,
},
);
emit(
ctx,
AgentEvent::Failed {
reason: sanitised_reason(&e),
},
);
return Err(e);
}
};
let latency_ms = llm_started.elapsed().as_millis() as u64;
let tokens = resp.usage.prompt_tokens + resp.usage.completion_tokens;
emit(ctx, AgentEvent::LlmCallCompleted { tokens, latency_ms });
check_post_llm(&opts.guardrails, &req, &resp).await?;
let outcome = StepOutcome {
message: resp.message,
finish_reason: resp.finish_reason,
usage: resp.usage,
latency_ms: latency_ms as u32,
};
match record_and_dispatch(ctx, &thread, step, outcome, "blocking").await? {
StepDisposition::Done(content) => {
emit(ctx, AgentEvent::Completed);
return Ok(content);
}
StepDisposition::Continue => continue,
}
}
}
pub(super) async fn build_request(
ctx: &AgentContext,
system_prompt: &str,
thread: &ThreadId,
max_history_tokens: usize,
) -> Result<ChatRequest, Error> {
let history = ctx
.short_term
.load(thread.clone(), max_history_tokens)
.await?;
let mut messages = Vec::with_capacity(history.len() + 1);
if !system_prompt.is_empty() {
messages.push(Message {
role: Role::System,
content: system_prompt.into(),
tool_calls: vec![],
tool_call_id: None,
});
}
messages.extend(history);
Ok(ChatRequest {
messages,
tools: ctx.tools.catalogue(),
..ChatRequest::new(vec![])
})
}