use phi_core::agent_loop::{agent_loop, AgentLoopConfig};
use phi_core::provider::mock::*;
use phi_core::provider::{MockProvider, ModelConfig};
use phi_core::*;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
struct SleepyTool {
name: String,
sleep: Duration,
override_timeout: Option<Duration>,
}
#[async_trait::async_trait]
impl AgentTool for SleepyTool {
fn name(&self) -> &str {
&self.name
}
fn label(&self) -> &str {
"Sleepy"
}
fn description(&self) -> &str {
"Test tool that sleeps."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: ToolContext,
) -> Result<ToolResult, ToolError> {
tokio::time::sleep(self.sleep).await;
Ok(ToolResult {
content: vec![Content::Text {
text: "slept ok".into(),
}],
details: serde_json::Value::Null,
child_loop_id: None,
})
}
fn timeout(&self) -> Option<Duration> {
self.override_timeout
}
}
fn make_config(provider: Arc<dyn phi_core::provider::StreamProvider>) -> AgentLoopConfig {
AgentLoopConfig {
model_config: ModelConfig::anthropic("mock", "mock", "test"),
provider_override: Some(provider),
thinking_level: ThinkingLevel::Off,
max_tokens: None,
temperature: None,
convert_to_llm: None,
transform_context: None,
get_steering_messages: None,
get_follow_up_messages: None,
context_config: None,
execution_limits: None,
cache_config: CacheConfig::default(),
tool_execution: ToolExecutionStrategy::default(),
tool_timeout: None,
response_format: phi_core::provider::ResponseFormat::Text,
retry_config: phi_core::RetryConfig::default(),
before_turn: None,
after_turn: None,
on_error: None,
before_loop: None,
after_loop: None,
before_tool_execution: None,
after_tool_execution: None,
before_tool_execution_update: None,
after_tool_execution_update: None,
before_compaction_start: None,
after_compaction_end: None,
input_filters: vec![],
first_turn_trigger: TurnTrigger::User,
config_id: None,
context_translation: None,
prun_pending: None,
}
}
fn make_context(tool: Arc<dyn AgentTool>) -> AgentContext {
AgentContext {
system_prompt: "You are helpful.".into(),
messages: Vec::new(),
tools: vec![tool],
agent_id: None,
session_id: None,
loop_id: None,
parent_loop_id: None,
continuation_kind: None,
session: None,
user_context: Vec::new(),
inrun_context: Vec::new(),
}
}
fn find_tool_result(msgs: &[AgentMessage]) -> Option<(&Vec<Content>, bool)> {
msgs.iter().find_map(|m| match m.as_llm() {
Some(Message::ToolResult {
content, is_error, ..
}) => Some((content, *is_error)),
_ => None,
})
}
#[tokio::test]
async fn tool_exceeding_config_timeout_returns_error_result() {
let provider = MockProvider::new(vec![
MockResponse::ToolCalls(vec![MockToolCall {
name: "sleepy".into(),
arguments: serde_json::json!({}),
}]),
MockResponse::Text("recovered from timeout".into()),
]);
let tool = SleepyTool {
name: "sleepy".into(),
sleep: Duration::from_secs(30),
override_timeout: None,
};
let mut config = make_config(Arc::new(provider));
config.tool_timeout = Some(Duration::from_millis(100));
let mut ctx = make_context(Arc::new(tool));
let prompt = AgentMessage::Llm(LlmMessage::new(Message::user("run sleepy")));
let (tx, _rx) = mpsc::unbounded_channel();
let cancel = CancellationToken::new();
let start = Instant::now();
let new_messages = agent_loop(vec![prompt], &mut ctx, &config, tx, cancel).await;
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"agent_loop should have returned promptly after tool timeout; took {:?}",
elapsed
);
let (content, is_error) =
find_tool_result(&new_messages).expect("a ToolResult message must be present");
assert!(is_error, "tool result must be flagged as error on timeout");
let text = match &content[0] {
Content::Text { text } => text.clone(),
other => panic!("expected Content::Text, got {:?}", other),
};
assert!(
text.contains("timeout") || text.contains("Timeout"),
"tool result text should mention timeout, got {:?}",
text
);
let last = new_messages.last().expect("at least one final message");
assert_eq!(last.role(), "assistant");
}
#[tokio::test]
async fn tool_override_timeout_beats_config_default() {
let provider = MockProvider::new(vec![
MockResponse::ToolCalls(vec![MockToolCall {
name: "sleepy".into(),
arguments: serde_json::json!({}),
}]),
MockResponse::Text("done".into()),
]);
let tool = SleepyTool {
name: "sleepy".into(),
sleep: Duration::from_secs(10),
override_timeout: Some(Duration::from_millis(50)),
};
let mut config = make_config(Arc::new(provider));
config.tool_timeout = Some(Duration::from_secs(5));
let mut ctx = make_context(Arc::new(tool));
let prompt = AgentMessage::Llm(LlmMessage::new(Message::user("run sleepy")));
let (tx, _rx) = mpsc::unbounded_channel();
let cancel = CancellationToken::new();
let start = Instant::now();
let _ = agent_loop(vec![prompt], &mut ctx, &config, tx, cancel).await;
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(1),
"per-tool override should have fired in ~50ms; took {:?}",
elapsed
);
}
#[tokio::test]
async fn tool_within_timeout_succeeds_normally() {
let provider = MockProvider::new(vec![
MockResponse::ToolCalls(vec![MockToolCall {
name: "sleepy".into(),
arguments: serde_json::json!({}),
}]),
MockResponse::Text("done".into()),
]);
let tool = SleepyTool {
name: "sleepy".into(),
sleep: Duration::from_millis(10),
override_timeout: None,
};
let mut config = make_config(Arc::new(provider));
config.tool_timeout = Some(Duration::from_secs(1));
let mut ctx = make_context(Arc::new(tool));
let prompt = AgentMessage::Llm(LlmMessage::new(Message::user("run sleepy")));
let (tx, _rx) = mpsc::unbounded_channel();
let cancel = CancellationToken::new();
let new_messages = agent_loop(vec![prompt], &mut ctx, &config, tx, cancel).await;
let (content, is_error) =
find_tool_result(&new_messages).expect("a ToolResult message must be present");
assert!(
!is_error,
"tool result should NOT be an error on fast success"
);
match &content[0] {
Content::Text { text } => assert_eq!(text, "slept ok"),
other => panic!("expected Content::Text, got {:?}", other),
}
}
#[tokio::test]
async fn tool_no_timeout_preserves_legacy_unbounded_behavior() {
let provider = MockProvider::new(vec![
MockResponse::ToolCalls(vec![MockToolCall {
name: "sleepy".into(),
arguments: serde_json::json!({}),
}]),
MockResponse::Text("done".into()),
]);
let tool = SleepyTool {
name: "sleepy".into(),
sleep: Duration::from_millis(20),
override_timeout: None,
};
let config = make_config(Arc::new(provider));
let mut ctx = make_context(Arc::new(tool));
let prompt = AgentMessage::Llm(LlmMessage::new(Message::user("run sleepy")));
let (tx, _rx) = mpsc::unbounded_channel();
let cancel = CancellationToken::new();
let new_messages = agent_loop(vec![prompt], &mut ctx, &config, tx, cancel).await;
let (_, is_error) =
find_tool_result(&new_messages).expect("a ToolResult message must be present");
assert!(!is_error);
}