phi_agent/agent/
builder.rs1#[cfg(any(feature = "file", feature = "skill"))]
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use agent_base::{ConsecutiveFailureRecovery, Language, ReasoningConfig, ReasoningEffort, UpdatePlanTool};
12
13#[cfg(feature = "compression")]
14use agent_works::compression::{CompressionConfig, CompressionMiddleware, ContextCompactor};
15
16#[cfg(feature = "compression")]
24static COMPACTOR: std::sync::Mutex<Option<ContextCompactor>> = std::sync::Mutex::new(None);
25
26pub fn clear_compression_cache() {
31 #[cfg(feature = "compression")]
32 {
33 if let Ok(guard) = COMPACTOR.lock()
34 && let Some(ref compactor) = *guard
35 {
36 compactor.clear_cache();
37 tracing::info!("compression cache cleared");
38 return;
39 }
40 tracing::warn!("clear_compression_cache: no compactor registered");
41 }
42}
43
44pub async fn run_compact_session(
55 runtime: &agent_base::AgentRuntime,
56 session_id: &agent_base::SessionId,
57 emit_fn: Option<std::sync::Arc<dyn Fn(agent_base::UserEvent) + Send + Sync>>,
58) -> agent_base::AgentResult<Option<bool>> {
59 #[cfg(feature = "compression")]
60 {
61 let handle = {
63 let guard = COMPACTOR
64 .lock()
65 .map_err(|e| agent_base::AgentError::internal(format!("COMPACTOR lock poisoned: {e}")))?;
66 guard.as_ref().map(|c| c.clone_handle())
67 };
68 if let Some(compactor) = handle {
69 return compactor.compact_session(runtime, session_id, emit_fn).await.map(Some);
70 }
71 tracing::warn!("run_compact_session: no compactor registered");
72 Ok(None)
73 }
74 #[cfg(not(feature = "compression"))]
75 {
76 let _ = (runtime, session_id, emit_fn);
77 Ok(None)
78 }
79}
80
81#[allow(unused_mut)]
109pub fn base_agent_builder(llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>) -> agent_works::AgentBuilder {
110 base_agent_builder_with_excludes(llm_client, Vec::new())
111}
112
113#[allow(unused_mut)]
118pub fn base_agent_builder_with_excludes(
119 llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
120 file_excludes: Vec<String>,
121) -> agent_works::AgentBuilder {
122 base_agent_builder_with_options(llm_client, file_excludes, None)
123}
124
125#[allow(unused_mut)]
129pub fn base_agent_builder_with_options(
130 llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
131 file_excludes: Vec<String>,
132 compression_config: Option<CompressionConfig>,
133) -> agent_works::AgentBuilder {
134 base_agent_builder_with_options_inner(llm_client, file_excludes, compression_config, false)
135}
136
137#[allow(unused_mut)]
141pub fn base_agent_builder_no_compression(
142 llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
143 file_excludes: Vec<String>,
144) -> agent_works::AgentBuilder {
145 base_agent_builder_with_options_inner(llm_client, file_excludes, None, true)
146}
147
148#[allow(unused_mut)]
149fn base_agent_builder_with_options_inner(
150 llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
151 file_excludes: Vec<String>,
152 compression_config: Option<CompressionConfig>,
153 skip_compression: bool,
154) -> agent_works::AgentBuilder {
155 let max_tool_output_chars = match std::env::var("PHI_MAX_TOOL_OUTPUT_CHARS") {
161 Ok(value) => match value.trim().parse::<usize>() {
162 Ok(n) => n,
163 Err(_) => {
164 tracing::warn!(
165 value = %value,
166 "PHI_MAX_TOOL_OUTPUT_CHARS is not a valid integer; falling back to default 4000"
167 );
168 4000
169 },
170 },
171 Err(_) => 4000,
172 };
173
174 #[cfg(feature = "file")]
175 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
176
177 let mut builder = agent_works::AgentBuilder::new(llm_client.clone())
178 .language(Language::En)
179 .reasoning(ReasoningConfig { effort: Some(ReasoningEffort::Medium), ..Default::default() })
180 .enable_thought(true)
181 .enable_thinking(true)
182 .max_sessions(50)
183 .max_turns_per_session(100)
184 .execution_max_turns(200)
185 .max_message_tokens(120_000)
192 .max_tool_output_chars(max_tool_output_chars)
193 .error_recovery(Arc::new(ConsecutiveFailureRecovery::new(3)));
194
195 #[cfg(feature = "compression")]
200 if !skip_compression {
201 let compactor = ContextCompactor::new(llm_client.clone(), compression_config.unwrap_or_default());
202 let handle = compactor.clone_handle();
204 if let Ok(mut guard) = COMPACTOR.lock() {
205 *guard = Some(handle);
206 }
207 builder = builder.middleware(CompressionMiddleware::from_compactor(compactor));
208 }
209
210 #[cfg(feature = "file")]
212 {
213 use phi_kernel_tools::file::{EditFileTool, ListFilesTool, ReadFileTool, WriteFileTool};
214 builder = builder
215 .register_tool_arc(Arc::new(ReadFileTool::new(cwd.clone())))
216 .register_tool_arc(Arc::new(WriteFileTool::new(cwd.clone())))
217 .register_tool_arc(Arc::new(EditFileTool::new(cwd.clone())))
218 .register_tool_arc(Arc::new(ListFilesTool::with_excludes(cwd.clone(), file_excludes)));
219 }
220
221 builder = builder.register_tool_arc(Arc::new(UpdatePlanTool::new()));
226
227 #[cfg(feature = "multi-agent")]
229 {
230 use agent_works::multi_agent::MultiAgentConfig;
231 let ma_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
237 builder = builder.with_multi_agent(MultiAgentConfig::default()).with_multi_agent_tool_factory(Arc::new(
238 move |runtime| phi_kernel_tools::multi_agent::create_all_tools(runtime, ma_cwd.clone()),
239 ));
240 }
241
242 #[cfg(feature = "skill")]
244 {
245 use agent_works::skill::Skill;
246 use agent_works::skill::prompt_skill::PromptSkill;
247 let skill_dirs: Vec<PathBuf> = vec![
248 dirs_next().join(".claude").join("skills"),
250 PathBuf::from(".claude/skills"),
252 ];
253
254 for dir in &skill_dirs {
255 if dir.is_dir() {
256 match PromptSkill::scan_dir(dir) {
257 Ok(skills) => {
258 for skill in skills {
259 tracing::debug!(
260 name = skill.name(),
261 dir = %dir.display(),
262 "auto-loaded skill (prompt-injection mode)"
263 );
264 builder = builder.register_skill(skill);
265 }
266 },
267 Err(e) => {
268 tracing::warn!(dir = %dir.display(), error = %e, "failed to scan skills directory");
269 },
270 }
271 }
272 }
273 }
274
275 builder
276}
277
278#[cfg(feature = "skill")]
280fn dirs_next() -> std::path::PathBuf {
281 std::env::var("HOME")
282 .or_else(|_| std::env::var("USERPROFILE"))
283 .map(PathBuf::from)
284 .unwrap_or_else(|_| PathBuf::from("."))
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use async_trait::async_trait;
291 use futures_core::Stream;
292 use std::pin::Pin;
293 use std::task::{Context, Poll};
294
295 struct StubClient;
296 struct EmptyStream;
297
298 impl Stream for EmptyStream {
299 type Item = Result<agent_base::StreamChunk, agent_base::llm_trait::LlmError>;
300 fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
301 Poll::Ready(None)
302 }
303 }
304
305 #[async_trait]
306 impl agent_base::llm_trait::LlmProvider for StubClient {
307 async fn stream(
308 &self,
309 _request: agent_base::llm_trait::ChatRequest,
310 ) -> Result<agent_base::llm_trait::ChatStream, agent_base::llm_trait::LlmError> {
311 Ok(agent_base::llm_trait::ChatStream::new(Box::pin(EmptyStream)))
312 }
313 async fn chat(
314 &self,
315 _request: agent_base::llm_trait::ChatRequest,
316 ) -> Result<agent_base::llm_trait::ChatResponse, agent_base::llm_trait::LlmError> {
317 Ok(agent_base::llm_trait::ChatResponse {
318 content: "stub".to_string(),
319 reasoning_content: None,
320 tool_calls: vec![],
321 usage: agent_base::UsageInfo::default(),
322 finish_reason: agent_base::llm_trait::FinishReason::Stop,
323 raw: None,
324 thinking_signature: None,
325 })
326 }
327 fn capabilities(&self) -> agent_base::llm_trait::Capabilities {
328 agent_base::llm_trait::Capabilities::default()
329 }
330 fn info(&self) -> agent_base::llm_trait::ProviderInfo {
331 agent_base::llm_trait::ProviderInfo { name: "stub".to_string(), model: "stub".to_string(), version: None }
332 }
333 }
334
335 #[test]
336 fn test_max_tool_output_chars_default() {
337 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
338 let builder = base_agent_builder(Arc::new(StubClient));
339 let _ = builder;
340 }
341
342 #[test]
343 fn test_max_tool_output_chars_custom() {
344 unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "8000") };
345 let builder = base_agent_builder(Arc::new(StubClient));
346 let _ = builder;
347 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
348 }
349
350 #[test]
351 fn test_max_tool_output_chars_invalid_fallback() {
352 unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "not-a-number") };
353 let builder = base_agent_builder(Arc::new(StubClient));
354 let _ = builder;
355 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
356 }
357
358 #[cfg(feature = "multi-agent")]
360 #[tokio::test(flavor = "multi_thread")]
361 async fn test_base_agent_builder_registers_multi_agent_tools() {
362 let builder = base_agent_builder(Arc::new(StubClient)).system_prompt("test");
363
364 let runtime = builder.build().unwrap();
365
366 let tools = tokio::task::block_in_place(|| {
367 let tools = runtime.tools_mut();
368 let guard = tools.blocking_read();
369 guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
370 });
371
372 assert!(tools.contains(&"spawn_agent".to_string()), "expected spawn_agent tool");
373 assert!(tools.contains(&"send_message".to_string()), "expected send_message tool");
374 assert!(!tools.contains(&"followup_task".to_string()), "followup_task must not be registered");
377 assert!(!tools.contains(&"wait_agent".to_string()), "wait_agent must not be registered");
381 assert!(tools.contains(&"list_agents".to_string()), "expected list_agents tool");
382 assert!(tools.contains(&"close_agent".to_string()), "expected close_agent tool");
383 }
384
385 #[cfg(feature = "multi-agent")]
387 #[tokio::test(flavor = "multi_thread")]
388 async fn test_base_agent_builder_without_multi_agent() {
389 let builder = base_agent_builder(Arc::new(StubClient)).system_prompt("test").without_multi_agent();
390
391 let runtime = builder.build().unwrap();
392
393 let tools = tokio::task::block_in_place(|| {
394 let tools = runtime.tools_mut();
395 let guard = tools.blocking_read();
396 guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
397 });
398
399 assert!(!tools.contains(&"spawn_agent".to_string()), "spawn_agent should not be registered");
400 assert!(!tools.contains(&"list_agents".to_string()), "list_agents should not be registered");
401 }
402}