use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use agents_core::agent::{
AgentDescriptor, AgentHandle, PlannerAction, PlannerContext, PlannerHandle, ToolHandle,
ToolResponse,
};
use agents_core::hitl::{AgentInterrupt, HitlAction, HitlInterrupt};
use agents_core::llm::LanguageModel;
use agents_core::messaging::{
AgentMessage, MessageContent, MessageMetadata, MessageRole, ToolInvocation,
};
use agents_core::persistence::{Checkpointer, ThreadId};
use agents_core::state::AgentStateSnapshot;
use async_trait::async_trait;
use serde_json::Value;
use crate::middleware::{
AgentMiddleware, AnthropicPromptCachingMiddleware, BaseSystemPromptMiddleware,
FilesystemMiddleware, HitlPolicy, HumanInLoopMiddleware, MiddlewareContext, ModelRequest,
PlanningMiddleware, SubAgentDescriptor, SubAgentMiddleware, SubAgentRegistration,
SummarizationMiddleware,
};
use crate::planner::LlmBackedPlanner;
use crate::providers::{
AnthropicConfig, AnthropicMessagesModel, GeminiChatModel, GeminiConfig, OpenAiChatModel,
OpenAiConfig,
};
// Built-in tool names exposed by middlewares. The `task` tool for subagents is not gated.
const BUILTIN_TOOL_NAMES: &[&str] = &["write_todos", "ls", "read_file", "write_file", "edit_file"];
/// Returns the default language model configured
/// Uses Claude Sonnet 4 with 64000 max tokens, mirroring the Python SDK defaults.
// todo: refactor this to openAI as Antropic are expensive for defailt opnes, gpt-5-mini can be used as defult
pub fn get_default_model() -> anyhow::Result<Arc<dyn LanguageModel>> {
let config = AnthropicConfig {
api_key: std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable is required"))?,
model: "claude-sonnet-4-20250514".to_string(),
max_output_tokens: 64000,
api_url: None,
api_version: None,
};
let model: Arc<dyn LanguageModel> = Arc::new(AnthropicMessagesModel::new(config)?);
Ok(model)
}
/// Configuration for building a deep agent instance.
pub struct DeepAgentConfig {
pub instructions: String,
pub planner: Arc<dyn PlannerHandle>,
pub tools: Vec<Arc<dyn ToolHandle>>,
pub subagents: Vec<SubAgentRegistration>,
pub summarization: Option<SummarizationConfig>,
pub tool_interrupts: HashMap<String, HitlPolicy>,
pub builtin_tools: Option<HashSet<String>>,
pub auto_general_purpose: bool,
pub enable_prompt_caching: bool,
pub checkpointer: Option<Arc<dyn Checkpointer>>,
}
/// Configuration for creating and registering a subagent using a simple, Python-like shape.
pub struct SubAgentConfig {
pub name: String,
pub description: String,
pub instructions: String,
pub tools: Option<Vec<Arc<dyn ToolHandle>>>,
pub planner: Option<Arc<dyn PlannerHandle>>,
}
impl IntoIterator for SubAgentConfig {
type Item = SubAgentConfig;
type IntoIter = std::iter::Once<SubAgentConfig>;
fn into_iter(self) -> Self::IntoIter {
std::iter::once(self)
}
}
/// Builder API to assemble a DeepAgent in a single fluent flow, mirroring the Python
/// `create_configurable_agent` experience. Prefer this for ergonomic construction.
pub struct ConfigurableAgentBuilder {
instructions: String,
planner: Option<Arc<dyn PlannerHandle>>,
tools: Vec<Arc<dyn ToolHandle>>,
subagents: Vec<SubAgentConfig>,
summarization: Option<SummarizationConfig>,
tool_interrupts: HashMap<String, HitlPolicy>,
builtin_tools: Option<HashSet<String>>,
auto_general_purpose: bool,
enable_prompt_caching: bool,
checkpointer: Option<Arc<dyn Checkpointer>>,
}
impl ConfigurableAgentBuilder {
pub fn new(instructions: impl Into<String>) -> Self {
Self {
instructions: instructions.into(),
planner: None,
tools: Vec::new(),
subagents: Vec::new(),
summarization: None,
tool_interrupts: HashMap::new(),
builtin_tools: None,
auto_general_purpose: true,
enable_prompt_caching: false,
checkpointer: None,
}
}
/// Set the language model for the agent (mirrors Python's `model` parameter)
pub fn with_model(mut self, model: Arc<dyn LanguageModel>) -> Self {
let planner: Arc<dyn PlannerHandle> = Arc::new(LlmBackedPlanner::new(model));
self.planner = Some(planner);
self
}
/// Low-level planner API (for advanced use cases)
pub fn with_planner(mut self, planner: Arc<dyn PlannerHandle>) -> Self {
self.planner = Some(planner);
self
}
/// Convenience method for OpenAI models (equivalent to model=OpenAiChatModel)
pub fn with_openai_chat(self, config: OpenAiConfig) -> anyhow::Result<Self> {
let model = Arc::new(OpenAiChatModel::new(config)?);
Ok(self.with_model(model))
}
/// Convenience method for Anthropic models (equivalent to model=AnthropicMessagesModel)
pub fn with_anthropic_messages(self, config: AnthropicConfig) -> anyhow::Result<Self> {
let model = Arc::new(AnthropicMessagesModel::new(config)?);
Ok(self.with_model(model))
}
/// Convenience method for Gemini models (equivalent to model=GeminiChatModel)
pub fn with_gemini_chat(self, config: GeminiConfig) -> anyhow::Result<Self> {
let model = Arc::new(GeminiChatModel::new(config)?);
Ok(self.with_model(model))
}
pub fn with_tool(mut self, tool: Arc<dyn ToolHandle>) -> Self {
self.tools.push(tool);
self
}
pub fn with_tools<I>(mut self, tools: I) -> Self
where
I: IntoIterator<Item = Arc<dyn ToolHandle>>,
{
self.tools.extend(tools);
self
}
pub fn with_subagent_config<I>(mut self, cfgs: I) -> Self
where
I: IntoIterator<Item = SubAgentConfig>,
{
self.subagents.extend(cfgs);
self
}
/// Convenience method: automatically create subagents from a list of tools.
/// Each tool becomes a specialized subagent with that single tool.
pub fn with_subagent_tools<I>(mut self, tools: I) -> Self
where
I: IntoIterator<Item = Arc<dyn ToolHandle>>,
{
for tool in tools {
let tool_name = tool.name();
let subagent_config = SubAgentConfig {
name: format!("{}-agent", tool_name),
description: format!("Specialized agent for {} operations", tool_name),
instructions: format!(
"You are a specialized agent. Use the {} tool to complete tasks efficiently.",
tool_name
),
tools: Some(vec![tool]),
planner: None, // Will inherit from parent
};
self.subagents.push(subagent_config);
}
self
}
pub fn with_summarization(mut self, config: SummarizationConfig) -> Self {
self.summarization = Some(config);
self
}
pub fn with_tool_interrupt(mut self, tool_name: impl Into<String>, policy: HitlPolicy) -> Self {
self.tool_interrupts.insert(tool_name.into(), policy);
self
}
pub fn with_builtin_tools<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.builtin_tools = Some(names.into_iter().map(|s| s.into()).collect());
self
}
pub fn with_auto_general_purpose(mut self, enabled: bool) -> Self {
self.auto_general_purpose = enabled;
self
}
pub fn with_prompt_caching(mut self, enabled: bool) -> Self {
self.enable_prompt_caching = enabled;
self
}
pub fn with_checkpointer(mut self, checkpointer: Arc<dyn Checkpointer>) -> Self {
self.checkpointer = Some(checkpointer);
self
}
pub fn build(self) -> anyhow::Result<DeepAgent> {
self.finalize(create_deep_agent_from_config)
}
/// Build an agent using the async constructor alias. This mirrors the Python
/// async_create_deep_agent entry point, while reusing the same runtime internals.
pub fn build_async(self) -> anyhow::Result<DeepAgent> {
self.finalize(create_async_deep_agent)
}
fn finalize(self, ctor: fn(DeepAgentConfig) -> DeepAgent) -> anyhow::Result<DeepAgent> {
let Self {
instructions,
planner,
tools,
subagents,
summarization,
tool_interrupts,
builtin_tools,
auto_general_purpose,
enable_prompt_caching,
checkpointer,
} = self;
let planner = planner
.ok_or_else(|| anyhow::anyhow!("model must be set (use with_model or with_*_chat)"))?;
let mut cfg = DeepAgentConfig::new(instructions, planner)
.with_auto_general_purpose(auto_general_purpose)
.with_prompt_caching(enable_prompt_caching);
if let Some(ckpt) = checkpointer {
cfg = cfg.with_checkpointer(ckpt);
}
if let Some(sum) = summarization {
cfg = cfg.with_summarization(sum);
}
if let Some(selected) = builtin_tools {
cfg = cfg.with_builtin_tools(selected);
}
for (name, policy) in tool_interrupts {
cfg = cfg.with_tool_interrupt(name, policy);
}
for tool in tools {
cfg = cfg.with_tool(tool);
}
for sub_cfg in subagents {
cfg = cfg.with_subagent_config(sub_cfg);
}
Ok(ctor(cfg))
}
}
#[derive(Clone)]
pub struct SummarizationConfig {
pub messages_to_keep: usize,
pub summary_note: String,
}
impl DeepAgentConfig {
pub fn new(instructions: impl Into<String>, planner: Arc<dyn PlannerHandle>) -> Self {
Self {
instructions: instructions.into(),
planner,
tools: Vec::new(),
subagents: Vec::new(),
summarization: None,
tool_interrupts: HashMap::new(),
builtin_tools: None,
auto_general_purpose: true,
enable_prompt_caching: false,
checkpointer: None,
}
}
pub fn with_tool(mut self, tool: Arc<dyn ToolHandle>) -> Self {
self.tools.push(tool);
self
}
pub fn with_subagent(
mut self,
descriptor: SubAgentDescriptor,
agent: Arc<dyn AgentHandle>,
) -> Self {
self.subagents
.push(SubAgentRegistration { descriptor, agent });
self
}
pub fn with_summarization(mut self, config: SummarizationConfig) -> Self {
self.summarization = Some(config);
self
}
pub fn with_tool_interrupt(mut self, tool_name: impl Into<String>, policy: HitlPolicy) -> Self {
self.tool_interrupts.insert(tool_name.into(), policy);
self
}
/// Limit which built-in tools are exposed. When omitted, all built-ins are available.
/// Built-ins: write_todos, ls, read_file, write_file, edit_file.
/// The `task` tool (for subagents) is always available when subagents are registered.
pub fn with_builtin_tools<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let set: HashSet<String> = names.into_iter().map(|s| s.into()).collect();
self.builtin_tools = Some(set);
self
}
/// Enable or disable automatic registration of a "general-purpose" subagent.
/// Enabled by default; set to false to opt out.
pub fn with_auto_general_purpose(mut self, enabled: bool) -> Self {
self.auto_general_purpose = enabled;
self
}
/// Enable or disable Anthropic prompt caching middleware.
/// Disabled by default; set to true to enable caching for better performance.
pub fn with_prompt_caching(mut self, enabled: bool) -> Self {
self.enable_prompt_caching = enabled;
self
}
/// Set the checkpointer for persisting agent state between runs.
pub fn with_checkpointer(mut self, checkpointer: Arc<dyn Checkpointer>) -> Self {
self.checkpointer = Some(checkpointer);
self
}
/// Convenience: construct and register a subagent from a simple configuration bundle.
pub fn with_subagent_config<I>(mut self, cfgs: I) -> Self
where
I: IntoIterator<Item = SubAgentConfig>,
{
for cfg in cfgs {
let planner = cfg.planner.unwrap_or_else(|| self.planner.clone());
let mut sub_cfg = DeepAgentConfig::new(cfg.instructions, planner)
.with_auto_general_purpose(false)
.with_prompt_caching(self.enable_prompt_caching);
if let Some(ref selected) = self.builtin_tools {
sub_cfg = sub_cfg.with_builtin_tools(selected.iter().cloned());
}
if let Some(ref sum) = self.summarization {
sub_cfg = sub_cfg.with_summarization(sum.clone());
}
if let Some(tools) = cfg.tools {
for t in tools {
sub_cfg = sub_cfg.with_tool(t);
}
} else {
for t in &self.tools {
sub_cfg = sub_cfg.with_tool(t.clone());
}
}
let sub_agent = create_deep_agent_from_config(sub_cfg);
self = self.with_subagent(
SubAgentDescriptor {
name: cfg.name,
description: cfg.description,
},
Arc::new(sub_agent),
);
}
self
}
pub fn with_openai_chat(
instructions: impl Into<String>,
config: OpenAiConfig,
) -> anyhow::Result<Self> {
let model: Arc<dyn LanguageModel> = Arc::new(OpenAiChatModel::new(config)?);
let planner: Arc<dyn PlannerHandle> = Arc::new(LlmBackedPlanner::new(model));
Ok(Self::new(instructions, planner))
}
pub fn with_anthropic_messages(
instructions: impl Into<String>,
config: AnthropicConfig,
) -> anyhow::Result<Self> {
let model: Arc<dyn LanguageModel> = Arc::new(AnthropicMessagesModel::new(config)?);
let planner: Arc<dyn PlannerHandle> = Arc::new(LlmBackedPlanner::new(model));
Ok(Self::new(instructions, planner))
}
pub fn with_gemini_chat(
instructions: impl Into<String>,
config: GeminiConfig,
) -> anyhow::Result<Self> {
let model: Arc<dyn LanguageModel> = Arc::new(GeminiChatModel::new(config)?);
let planner: Arc<dyn PlannerHandle> = Arc::new(LlmBackedPlanner::new(model));
Ok(Self::new(instructions, planner))
}
}
pub struct CreateDeepAgentParams {
pub tools: Vec<Arc<dyn ToolHandle>>,
pub instructions: String,
pub middleware: Vec<Arc<dyn AgentMiddleware>>,
pub model: Option<Arc<dyn LanguageModel>>,
pub subagents: Vec<SubAgentConfig>,
pub context_schema: Option<String>,
pub checkpointer: Option<Arc<dyn Checkpointer>>,
pub tool_configs: HashMap<String, HitlPolicy>,
}
impl Default for CreateDeepAgentParams {
fn default() -> Self {
Self {
tools: Vec::new(),
instructions: String::new(),
middleware: Vec::new(),
model: None,
subagents: Vec::new(),
context_schema: None,
checkpointer: None,
tool_configs: HashMap::new(),
}
}
}
/// Create a deep agent - matches Python create_deep_agent() API exactly
pub fn create_deep_agent(params: CreateDeepAgentParams) -> anyhow::Result<DeepAgent> {
let CreateDeepAgentParams {
tools,
instructions,
middleware,
model,
subagents,
context_schema,
checkpointer,
tool_configs,
} = params;
if context_schema.is_some() {
tracing::warn!(
"context_schema parameter for create_deep_agent is not yet supported in Rust SDK"
);
}
if !middleware.is_empty() {
tracing::warn!("middleware parameter for create_deep_agent is not yet supported in Rust SDK ({} entries)", middleware.len());
}
let mut builder = ConfigurableAgentBuilder::new(instructions);
let model = match model {
Some(model) => model,
None => get_default_model()?,
};
builder = builder.with_model(model);
if !tools.is_empty() {
builder = builder.with_tools(tools);
}
if !subagents.is_empty() {
builder = builder.with_subagent_config(subagents);
}
if let Some(checkpointer) = checkpointer {
builder = builder.with_checkpointer(checkpointer);
}
if !tool_configs.is_empty() {
for (tool, policy) in tool_configs {
builder = builder.with_tool_interrupt(tool, policy);
}
}
builder.build()
}
pub fn create_deep_agent_from_config(config: DeepAgentConfig) -> DeepAgent {
let state = Arc::new(RwLock::new(AgentStateSnapshot::default()));
let history = Arc::new(RwLock::new(Vec::<AgentMessage>::new()));
let planning = Arc::new(PlanningMiddleware::new(state.clone()));
let filesystem = Arc::new(FilesystemMiddleware::new(state.clone()));
// Prepare subagent registrations, optionally injecting a general-purpose subagent
let mut registrations = config.subagents.clone();
if config.auto_general_purpose {
let has_gp = registrations
.iter()
.any(|r| r.descriptor.name == "general-purpose");
if !has_gp {
// Create a subagent with inherited planner/tools and same instructions
let mut sub_cfg =
DeepAgentConfig::new(config.instructions.clone(), config.planner.clone())
.with_auto_general_purpose(false)
.with_prompt_caching(config.enable_prompt_caching);
if let Some(ref selected) = config.builtin_tools {
sub_cfg = sub_cfg.with_builtin_tools(selected.iter().cloned());
}
if let Some(ref sum) = config.summarization {
sub_cfg = sub_cfg.with_summarization(sum.clone());
}
for t in &config.tools {
sub_cfg = sub_cfg.with_tool(t.clone());
}
let gp = create_deep_agent_from_config(sub_cfg);
registrations.push(SubAgentRegistration {
descriptor: SubAgentDescriptor {
name: "general-purpose".into(),
description: "Default reasoning agent".into(),
},
agent: Arc::new(gp),
});
}
}
let subagent = Arc::new(SubAgentMiddleware::new(registrations));
let base_prompt = Arc::new(BaseSystemPromptMiddleware);
let summarization = config.summarization.as_ref().map(|cfg| {
Arc::new(SummarizationMiddleware::new(
cfg.messages_to_keep,
cfg.summary_note.clone(),
))
});
let hitl = if config.tool_interrupts.is_empty() {
None
} else {
Some(Arc::new(HumanInLoopMiddleware::new(
config.tool_interrupts.clone(),
)))
};
let mut middlewares: Vec<Arc<dyn crate::middleware::AgentMiddleware>> =
vec![base_prompt, planning, filesystem, subagent];
if let Some(ref summary) = summarization {
middlewares.push(summary.clone());
}
if config.enable_prompt_caching {
middlewares.push(Arc::new(AnthropicPromptCachingMiddleware::default()));
}
if let Some(ref hitl_mw) = hitl {
middlewares.push(hitl_mw.clone());
}
DeepAgent {
descriptor: AgentDescriptor {
name: "deep-agent".into(),
version: "0.0.1".into(),
description: Some("Rust deep agent".into()),
},
instructions: config.instructions,
planner: config.planner,
middlewares,
base_tools: config.tools,
state,
history,
_summarization: summarization,
hitl,
pending_hitl: Arc::new(RwLock::new(None)),
builtin_tools: config.builtin_tools,
checkpointer: config.checkpointer,
}
}
/// Async constructor alias to mirror the Python API surface. The underlying
/// runtime already executes every tool and planner call asynchronously, so this
/// currently delegates to `create_deep_agent`.
pub fn create_async_deep_agent(config: DeepAgentConfig) -> DeepAgent {
create_deep_agent_from_config(config)
}
pub struct DeepAgent {
descriptor: AgentDescriptor,
instructions: String,
planner: Arc<dyn PlannerHandle>,
middlewares: Vec<Arc<dyn crate::middleware::AgentMiddleware>>,
base_tools: Vec<Arc<dyn ToolHandle>>,
state: Arc<RwLock<AgentStateSnapshot>>,
history: Arc<RwLock<Vec<AgentMessage>>>,
_summarization: Option<Arc<SummarizationMiddleware>>,
hitl: Option<Arc<HumanInLoopMiddleware>>,
pending_hitl: Arc<RwLock<Option<HitlPending>>>,
builtin_tools: Option<HashSet<String>>,
checkpointer: Option<Arc<dyn Checkpointer>>,
}
struct HitlPending {
tool_name: String,
payload: Value,
tool: Arc<dyn ToolHandle>,
message: AgentMessage,
}
impl DeepAgent {
fn collect_tools(&self) -> HashMap<String, Arc<dyn ToolHandle>> {
let mut tools: HashMap<String, Arc<dyn ToolHandle>> = HashMap::new();
for tool in &self.base_tools {
tools.insert(tool.name().to_string(), tool.clone());
}
for middleware in &self.middlewares {
for tool in middleware.tools() {
if self.should_include(tool.name()) {
tools.insert(tool.name().to_string(), tool);
}
}
}
tools
}
fn should_include(&self, name: &str) -> bool {
let is_builtin = BUILTIN_TOOL_NAMES.contains(&name);
if !is_builtin {
return true;
}
match &self.builtin_tools {
None => true,
Some(selected) => selected.contains(name),
}
}
fn append_history(&self, message: AgentMessage) {
if let Ok(mut history) = self.history.write() {
history.push(message);
}
}
fn current_history(&self) -> Vec<AgentMessage> {
self.history.read().map(|h| h.clone()).unwrap_or_default()
}
/// Save the current agent state to the configured checkpointer.
pub async fn save_state(&self, thread_id: &ThreadId) -> anyhow::Result<()> {
if let Some(ref checkpointer) = self.checkpointer {
let state = self
.state
.read()
.map_err(|_| anyhow::anyhow!("Failed to read agent state"))?
.clone();
checkpointer.save_state(thread_id, &state).await
} else {
tracing::warn!("Attempted to save state but no checkpointer is configured");
Ok(())
}
}
/// Load agent state from the configured checkpointer.
pub async fn load_state(&self, thread_id: &ThreadId) -> anyhow::Result<bool> {
if let Some(ref checkpointer) = self.checkpointer {
if let Some(saved_state) = checkpointer.load_state(thread_id).await? {
*self
.state
.write()
.map_err(|_| anyhow::anyhow!("Failed to write agent state"))? = saved_state;
tracing::info!(thread_id = %thread_id, "Loaded agent state from checkpointer");
Ok(true)
} else {
tracing::debug!(thread_id = %thread_id, "No saved state found for thread");
Ok(false)
}
} else {
tracing::warn!("Attempted to load state but no checkpointer is configured");
Ok(false)
}
}
/// Delete saved state for the specified thread.
pub async fn delete_thread(&self, thread_id: &ThreadId) -> anyhow::Result<()> {
if let Some(ref checkpointer) = self.checkpointer {
checkpointer.delete_thread(thread_id).await
} else {
tracing::warn!("Attempted to delete thread state but no checkpointer is configured");
Ok(())
}
}
/// List all threads with saved state.
pub async fn list_threads(&self) -> anyhow::Result<Vec<ThreadId>> {
if let Some(ref checkpointer) = self.checkpointer {
checkpointer.list_threads().await
} else {
Ok(Vec::new())
}
}
async fn execute_tool(
&self,
tool: Arc<dyn ToolHandle>,
tool_name: String,
payload: Value,
) -> anyhow::Result<AgentMessage> {
let response = tool
.invoke(ToolInvocation {
tool_name: tool_name.clone(),
args: payload,
tool_call_id: None,
})
.await?;
Ok(self.apply_tool_response(response))
}
fn apply_tool_response(&self, response: ToolResponse) -> AgentMessage {
match response {
ToolResponse::Message(message) => {
self.append_history(message.clone());
message
}
ToolResponse::Command(command) => {
if let Ok(mut state) = self.state.write() {
command.clone().apply_to(&mut state);
}
let mut final_message = None;
for message in &command.messages {
self.append_history(message.clone());
final_message = Some(message.clone());
}
final_message.unwrap_or_else(|| AgentMessage {
role: MessageRole::Tool,
content: MessageContent::Text("Command executed.".into()),
metadata: Some(MessageMetadata {
tool_call_id: None,
cache_control: None,
}),
})
}
}
}
pub fn current_interrupt(&self) -> Option<AgentInterrupt> {
self.pending_hitl.read().ok().and_then(|guard| {
guard.as_ref().map(|pending| {
AgentInterrupt::HumanInLoop(HitlInterrupt {
tool_name: pending.tool_name.clone(),
message: pending.message.clone(),
})
})
})
}
pub async fn resume_hitl(&self, action: HitlAction) -> anyhow::Result<AgentMessage> {
let pending = self
.pending_hitl
.write()
.ok()
.and_then(|mut guard| guard.take())
.ok_or_else(|| anyhow::anyhow!("No pending HITL action"))?;
match action {
HitlAction::Approve => {
let result = self
.execute_tool(
pending.tool.clone(),
pending.tool_name.clone(),
pending.payload.clone(),
)
.await?;
Ok(result)
}
HitlAction::Reject { reason } => {
let text =
reason.unwrap_or_else(|| "Tool execution rejected by human reviewer.".into());
let message = AgentMessage {
role: MessageRole::System,
content: MessageContent::Text(text),
metadata: None,
};
self.append_history(message.clone());
Ok(message)
}
HitlAction::Respond { message } => {
self.append_history(message.clone());
Ok(message)
}
HitlAction::Edit { action, args } => {
// Execute the edited tool/action with provided args
let tools = self.collect_tools();
if let Some(tool) = tools.get(&action).cloned() {
let result = self.execute_tool(tool, action, args).await?;
Ok(result)
} else {
Ok(AgentMessage {
role: MessageRole::System,
content: MessageContent::Text(format!(
"Edited tool '{}' not available",
action
)),
metadata: None,
})
}
}
}
}
/// Handle message from string input - converts string to AgentMessage internally
pub async fn handle_message(
&self,
input: impl AsRef<str>,
state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<AgentMessage> {
self.handle_message_with_metadata(input, None, state).await
}
/// Handle message from string input with metadata - converts string to AgentMessage internally
pub async fn handle_message_with_metadata(
&self,
input: impl AsRef<str>,
metadata: Option<MessageMetadata>,
state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<AgentMessage> {
let agent_message = AgentMessage {
role: MessageRole::User,
content: MessageContent::Text(input.as_ref().to_string()),
metadata,
};
self.handle_message_internal(agent_message, state).await
}
}
#[async_trait]
impl AgentHandle for DeepAgent {
async fn describe(&self) -> AgentDescriptor {
self.descriptor.clone()
}
async fn handle_message(
&self,
input: AgentMessage,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<AgentMessage> {
self.handle_message_internal(input, _state).await
}
}
impl DeepAgent {
/// Internal method that contains the actual message handling logic
async fn handle_message_internal(
&self,
input: AgentMessage,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<AgentMessage> {
self.append_history(input.clone());
let mut request = ModelRequest::new(&self.instructions, self.current_history());
let tools = self.collect_tools();
for middleware in &self.middlewares {
let mut ctx = MiddlewareContext::with_request(&mut request, self.state.clone());
middleware.modify_model_request(&mut ctx).await?;
}
let tool_schemas: Vec<_> = tools.values().map(|t| t.schema()).collect();
let context = PlannerContext {
history: request.messages.clone(),
system_prompt: request.system_prompt.clone(),
tools: tool_schemas,
};
let state_snapshot = Arc::new(self.state.read().map(|s| s.clone()).unwrap_or_default());
let decision = self.planner.plan(context, state_snapshot).await?;
match decision.next_action {
PlannerAction::Respond { message } => {
self.append_history(message.clone());
Ok(message)
}
PlannerAction::CallTool { tool_name, payload } => {
if let Some(tool) = tools.get(&tool_name).cloned() {
if let Some(hitl) = &self.hitl {
if let Some(policy) = hitl.requires_approval(&tool_name) {
let message_text = policy
.note
.clone()
.unwrap_or_else(|| "Awaiting human approval.".into());
let approval_message = AgentMessage {
role: MessageRole::System,
content: MessageContent::Text(format!(
"HITL_REQUIRED: Tool '{tool}' requires approval: {message}",
tool = tool_name,
message = message_text
)),
metadata: None,
};
let pending = HitlPending {
tool_name: tool_name.clone(),
payload: payload.clone(),
tool: tool.clone(),
message: approval_message.clone(),
};
if let Ok(mut guard) = self.pending_hitl.write() {
*guard = Some(pending);
}
self.append_history(approval_message.clone());
return Ok(approval_message);
}
}
self.execute_tool(tool.clone(), tool_name.clone(), payload.clone())
.await
} else {
Ok(AgentMessage {
role: MessageRole::Tool,
content: MessageContent::Text(format!(
"Tool '{tool}' not available",
tool = tool_name
)),
metadata: Some(MessageMetadata {
tool_call_id: None,
cache_control: None,
}),
})
}
}
PlannerAction::Terminate => Ok(AgentMessage {
role: MessageRole::Agent,
content: MessageContent::Text("Terminating conversation.".into()),
metadata: Some(MessageMetadata {
tool_call_id: None,
cache_control: None,
}),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use agents_core::agent::{PlannerDecision, PlannerHandle, ToolHandle, ToolResponse};
use async_trait::async_trait;
use serde_json::json;
async fn send_user(agent: &DeepAgent, text: &str) -> AgentMessage {
agent
.handle_message(
text,
Arc::new(AgentStateSnapshot::default()),
)
.await
.unwrap()
}
struct EchoPlanner;
#[async_trait]
impl PlannerHandle for EchoPlanner {
async fn plan(
&self,
context: PlannerContext,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<PlannerDecision> {
let last_user = context
.history
.iter()
.rev()
.find(|msg| matches!(msg.role, MessageRole::User))
.cloned()
.unwrap_or(AgentMessage {
role: MessageRole::User,
content: MessageContent::Text("".into()),
metadata: None,
});
Ok(PlannerDecision {
next_action: PlannerAction::Respond {
message: AgentMessage {
role: MessageRole::Agent,
content: last_user.content,
metadata: None,
},
},
})
}
}
#[tokio::test]
async fn deep_agent_echoes() {
let planner = Arc::new(EchoPlanner);
let agent = create_deep_agent_from_config(DeepAgentConfig::new("Be helpful", planner));
let response = send_user(&agent, "hello").await;
match response.content {
MessageContent::Text(text) => assert_eq!(text, "hello"),
other => panic!("expected text, got {other:?}"),
}
}
struct LsPlanner;
#[async_trait]
impl PlannerHandle for LsPlanner {
async fn plan(
&self,
_context: PlannerContext,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<PlannerDecision> {
Ok(PlannerDecision {
next_action: PlannerAction::CallTool {
tool_name: "ls".into(),
payload: json!({}),
},
})
}
}
#[tokio::test]
async fn builtin_tools_can_be_filtered() {
let planner = Arc::new(LsPlanner);
// Allow only write_todos; ls should be filtered out
let agent = create_deep_agent_from_config(
DeepAgentConfig::new("Assist", planner).with_builtin_tools(["write_todos"]),
);
let response = send_user(&agent, "list files").await;
if let MessageContent::Text(text) = response.content {
assert!(text.contains("Tool 'ls' not available"));
} else {
panic!("expected text response");
}
}
struct DelegatePlanner;
#[async_trait]
impl PlannerHandle for DelegatePlanner {
async fn plan(
&self,
_context: PlannerContext,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<PlannerDecision> {
Ok(PlannerDecision {
next_action: PlannerAction::CallTool {
tool_name: "task".into(),
payload: json!({
"description": "Handle delegation",
"subagent_type": "stub-agent"
}),
},
})
}
}
struct StubSubAgent;
#[async_trait]
impl AgentHandle for StubSubAgent {
async fn describe(&self) -> AgentDescriptor {
AgentDescriptor {
name: "stub-subagent".into(),
version: "0.0.1".into(),
description: None,
}
}
async fn handle_message(
&self,
_input: AgentMessage,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<AgentMessage> {
Ok(AgentMessage {
role: MessageRole::Agent,
content: MessageContent::Text("delegated-result".into()),
metadata: None,
})
}
}
#[tokio::test]
async fn deep_agent_delegates_to_subagent() {
let planner = Arc::new(DelegatePlanner);
let config = DeepAgentConfig::new("Use tools", planner).with_subagent(
SubAgentDescriptor {
name: "stub-agent".into(),
description: "Stub Agent".into(),
},
Arc::new(StubSubAgent),
);
let agent = create_deep_agent_from_config(config);
let response = send_user(&agent, "delegate").await;
assert!(matches!(response.role, MessageRole::Tool));
match response.content {
MessageContent::Text(text) => assert_eq!(text, "delegated-result"),
other => panic!("expected text, got {other:?}"),
}
}
struct AlwaysTextPlanner(&'static str);
#[async_trait]
impl PlannerHandle for AlwaysTextPlanner {
async fn plan(
&self,
_context: PlannerContext,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<PlannerDecision> {
Ok(PlannerDecision {
next_action: PlannerAction::Respond {
message: AgentMessage {
role: MessageRole::Agent,
content: MessageContent::Text(self.0.to_string()),
metadata: None,
},
},
})
}
}
#[allow(dead_code)]
struct GpDelegatePlanner;
#[async_trait]
impl PlannerHandle for GpDelegatePlanner {
async fn plan(
&self,
_context: PlannerContext,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<PlannerDecision> {
Ok(PlannerDecision {
next_action: PlannerAction::CallTool {
tool_name: "task".into(),
payload: json!({
"description": "Ask GP agent",
"subagent_type": "general-purpose"
}),
},
})
}
}
#[tokio::test]
async fn default_general_purpose_subagent_is_available() {
// Main agent delegates to general-purpose; GP uses AlwaysTextPlanner to respond
// let main_planner = Arc::new(GpDelegatePlanner);
let gp_planner = Arc::new(AlwaysTextPlanner("gp-ok"));
// Build agent but override planner for the GP by setting it as the main planner
// and ensuring GP inherits it
let agent = create_deep_agent_from_config(DeepAgentConfig::new("Assist", gp_planner));
let response = send_user(&agent, "delegate to gp").await;
match response.content {
MessageContent::Text(text) => assert_eq!(text, "gp-ok"),
other => panic!("expected text, got {other:?}"),
}
}
#[tokio::test]
async fn subagent_convenience_builder_registers_and_delegates() {
let main_planner = Arc::new(DelegatePlanner);
let custom_planner = Arc::new(AlwaysTextPlanner("custom-ok"));
let agent = create_deep_agent_from_config(
DeepAgentConfig::new("Assist", main_planner).with_subagent_config(SubAgentConfig {
name: "stub-agent".into(),
description: "Stub Agent".into(),
instructions: "Custom".into(),
tools: None,
planner: Some(custom_planner),
}),
);
let response = send_user(&agent, "delegate").await;
match response.content {
MessageContent::Text(text) => assert_eq!(text, "custom-ok"),
other => panic!("expected text, got {other:?}"),
}
}
struct AlwaysRespondPlanner;
#[async_trait]
impl PlannerHandle for AlwaysRespondPlanner {
async fn plan(
&self,
context: PlannerContext,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<PlannerDecision> {
Ok(PlannerDecision {
next_action: PlannerAction::Respond {
message: AgentMessage {
role: MessageRole::Agent,
content: MessageContent::Text(
context
.history
.last()
.and_then(|m| m.content.as_text())
.unwrap_or("")
.to_string(),
),
metadata: None,
},
},
})
}
}
#[tokio::test]
async fn deep_agent_applies_summarization() {
let planner = Arc::new(AlwaysRespondPlanner);
let agent = create_deep_agent_from_config(
DeepAgentConfig::new("Assist", planner).with_summarization(SummarizationConfig {
messages_to_keep: 1,
summary_note: "Summary".into(),
}),
);
send_user(&agent, "first").await;
let response = send_user(&agent, "second").await;
if let MessageContent::Text(text) = response.content {
assert_eq!(text, "second");
}
}
struct SensitiveTool;
#[async_trait]
impl ToolHandle for SensitiveTool {
fn name(&self) -> &str {
"sensitive"
}
async fn invoke(&self, _invocation: ToolInvocation) -> anyhow::Result<ToolResponse> {
Ok(ToolResponse::Message(AgentMessage {
role: MessageRole::Tool,
content: MessageContent::Text("tool-output".into()),
metadata: None,
}))
}
}
struct ToolPlanner;
#[async_trait]
impl PlannerHandle for ToolPlanner {
async fn plan(
&self,
_context: PlannerContext,
_state: Arc<AgentStateSnapshot>,
) -> anyhow::Result<PlannerDecision> {
Ok(PlannerDecision {
next_action: PlannerAction::CallTool {
tool_name: "sensitive".into(),
payload: json!({}),
},
})
}
}
#[tokio::test]
async fn deep_agent_requires_hitl() {
let planner = Arc::new(ToolPlanner);
let config = DeepAgentConfig::new("Assist", planner)
.with_tool(Arc::new(SensitiveTool))
.with_tool_interrupt(
"sensitive",
HitlPolicy {
allow_auto: false,
note: Some("Needs approval".into()),
},
);
let agent = create_deep_agent_from_config(config);
let response = send_user(&agent, "call tool").await;
match response.content {
MessageContent::Text(text) => assert!(text.contains("HITL_REQUIRED")),
other => panic!("expected text, got {other:?}"),
}
assert!(matches!(
agent.current_interrupt(),
Some(AgentInterrupt::HumanInLoop(_))
));
}
struct NoopTool;
#[async_trait]
impl ToolHandle for NoopTool {
fn name(&self) -> &str {
"noop"
}
async fn invoke(&self, _invocation: ToolInvocation) -> anyhow::Result<ToolResponse> {
Ok(ToolResponse::Message(AgentMessage {
role: MessageRole::Tool,
content: MessageContent::Text("edited-ok".into()),
metadata: None,
}))
}
}
#[tokio::test]
async fn hitl_edit_changes_tool_and_args() {
// Planner calls 'sensitive' which requires approval; we then edit to call 'noop'.
let planner = Arc::new(ToolPlanner);
let config = DeepAgentConfig::new("Assist", planner)
.with_tool(Arc::new(SensitiveTool))
.with_tool(Arc::new(NoopTool))
.with_tool_interrupt(
"sensitive",
HitlPolicy {
allow_auto: false,
note: Some("Needs approval".into()),
},
);
let agent = create_deep_agent_from_config(config);
let response = send_user(&agent, "call tool").await;
match response.content {
MessageContent::Text(text) => assert!(text.contains("HITL_REQUIRED")),
other => panic!("expected text, got {other:?}"),
}
assert!(matches!(
agent.current_interrupt(),
Some(AgentInterrupt::HumanInLoop(_))
));
let edited = agent
.resume_hitl(HitlAction::Edit {
action: "noop".into(),
args: json!({}),
})
.await
.unwrap();
match edited.content {
MessageContent::Text(text) => assert_eq!(text, "edited-ok"),
other => panic!("expected text, got {other:?}"),
}
}
#[tokio::test]
async fn agent_builder_supports_prompt_caching() {
let planner = Arc::new(EchoPlanner);
let agent = ConfigurableAgentBuilder::new("test prompt caching")
.with_planner(planner)
.with_prompt_caching(true)
.build()
.unwrap();
let response = send_user(&agent, "hello").await;
// Echo planner just returns the input, so this verifies the agent works with caching enabled
assert_eq!(response.content.as_text().unwrap(), "hello");
}
#[tokio::test]
async fn agent_builder_supports_checkpointer() {
use agents_core::persistence::InMemoryCheckpointer;
let planner = Arc::new(EchoPlanner);
let checkpointer = Arc::new(InMemoryCheckpointer::new());
let agent = ConfigurableAgentBuilder::new("test checkpointer")
.with_planner(planner)
.with_checkpointer(checkpointer)
.build()
.unwrap();
// Test that we can save and load state
let thread_id = "test-thread".to_string();
agent.save_state(&thread_id).await.unwrap();
// Load should return true (state was found and loaded)
let loaded = agent.load_state(&thread_id).await.unwrap();
assert!(loaded);
// Test listing threads
let threads = agent.list_threads().await.unwrap();
assert!(threads.contains(&thread_id));
// Clean up
agent.delete_thread(&thread_id).await.unwrap();
let threads_after = agent.list_threads().await.unwrap();
assert!(!threads_after.contains(&thread_id));
}
#[tokio::test]
async fn agent_builder_with_model_mirrors_python_api() {
use agents_core::llm::{LlmRequest, LlmResponse};
use async_trait::async_trait;
// Mock model that mirrors Python API usage
struct MockLanguageModel;
#[async_trait]
impl LanguageModel for MockLanguageModel {
async fn generate(&self, _request: LlmRequest) -> anyhow::Result<LlmResponse> {
Ok(LlmResponse {
message: AgentMessage {
role: MessageRole::Agent,
content: MessageContent::Text("model response".into()),
metadata: None,
},
})
}
}
// This mirrors Python: create_deep_agent(model=some_model, ...)
let model = Arc::new(MockLanguageModel);
let agent = ConfigurableAgentBuilder::new("test model")
.with_model(model) // ← This mirrors Python's model= parameter
.build()
.unwrap();
let response = send_user(&agent, "hello").await;
assert_eq!(response.content.as_text().unwrap(), "model response");
}
#[test]
fn test_get_default_model_requires_api_key() {
// Save current state
let original_key = std::env::var("ANTHROPIC_API_KEY").ok();
// Remove the key to test error case
std::env::remove_var("ANTHROPIC_API_KEY");
let result = get_default_model();
assert!(result.is_err());
let err_msg = format!("{}", result.err().unwrap());
assert!(err_msg.contains("ANTHROPIC_API_KEY"));
// Restore original state
if let Some(key) = original_key {
std::env::set_var("ANTHROPIC_API_KEY", key);
}
}
#[test]
fn test_get_default_model_with_api_key() {
// Save current state
let original_key = std::env::var("ANTHROPIC_API_KEY").ok();
// Set test key
std::env::set_var("ANTHROPIC_API_KEY", "test-key");
let result = get_default_model();
assert!(result.is_ok());
// Restore original state
match original_key {
Some(key) => std::env::set_var("ANTHROPIC_API_KEY", key),
None => std::env::remove_var("ANTHROPIC_API_KEY"),
}
}
#[tokio::test]
async fn test_configurable_agent_with_default_model() {
// Save current state
let original_key = std::env::var("ANTHROPIC_API_KEY").ok();
// Test using get_default_model with the builder pattern
std::env::set_var("ANTHROPIC_API_KEY", "test-key");
let model = get_default_model().unwrap();
let agent = ConfigurableAgentBuilder::new("test instructions")
.with_model(model) // Use the default model
.build()
.unwrap();
// Agent should be created successfully
let descriptor = agent.describe().await;
assert_eq!(descriptor.name, "deep-agent");
// Restore original state
match original_key {
Some(key) => std::env::set_var("ANTHROPIC_API_KEY", key),
None => std::env::remove_var("ANTHROPIC_API_KEY"),
}
}
}