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::StreamClient>) -> 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::StreamClient>,
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::StreamClient>,
131 file_excludes: Vec<String>,
132 compression_config: Option<CompressionConfig>,
133) -> agent_works::AgentBuilder {
134 let max_tool_output_chars = match std::env::var("PHI_MAX_TOOL_OUTPUT_CHARS") {
140 Ok(value) => match value.trim().parse::<usize>() {
141 Ok(n) => n,
142 Err(_) => {
143 tracing::warn!(
144 value = %value,
145 "PHI_MAX_TOOL_OUTPUT_CHARS is not a valid integer; falling back to default 4000"
146 );
147 4000
148 },
149 },
150 Err(_) => 4000,
151 };
152
153 #[cfg(feature = "file")]
154 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
155
156 let mut builder = agent_works::AgentBuilder::new(llm_client.clone())
157 .language(Language::En)
158 .reasoning(ReasoningConfig { effort: Some(ReasoningEffort::Medium), ..Default::default() })
159 .enable_thought(true)
160 .enable_thinking(true)
161 .max_sessions(50)
162 .max_turns_per_session(100)
163 .execution_max_turns(200)
164 .max_message_tokens(50_000)
165 .max_tool_output_chars(max_tool_output_chars)
166 .error_recovery(Arc::new(ConsecutiveFailureRecovery::new(3)));
167
168 #[cfg(feature = "compression")]
171 {
172 let compactor = ContextCompactor::new(llm_client.clone(), compression_config.unwrap_or_default());
173 let handle = compactor.clone_handle();
175 if let Ok(mut guard) = COMPACTOR.lock() {
176 *guard = Some(handle);
177 }
178 builder = builder.middleware(CompressionMiddleware::from_compactor(compactor));
179 }
180
181 #[cfg(feature = "file")]
183 {
184 use phi_kernel_tools::file::{EditFileTool, ListFilesTool, ReadFileTool, WriteFileTool};
185 builder = builder
186 .register_tool_arc(Arc::new(ReadFileTool::new(cwd.clone())))
187 .register_tool_arc(Arc::new(WriteFileTool::new(cwd.clone())))
188 .register_tool_arc(Arc::new(EditFileTool::new(cwd.clone())))
189 .register_tool_arc(Arc::new(ListFilesTool::with_excludes(cwd.clone(), file_excludes)));
190 }
191
192 builder = builder.register_tool_arc(Arc::new(UpdatePlanTool::new()));
197
198 #[cfg(feature = "multi-agent")]
200 {
201 use agent_works::multi_agent::MultiAgentConfig;
202 builder =
203 builder.with_multi_agent(MultiAgentConfig::default()).with_multi_agent_tool_factory(Arc::new(|runtime| {
204 phi_kernel_tools::multi_agent::create_all_tools(runtime)
205 }));
206 }
207
208 #[cfg(feature = "skill")]
210 {
211 use agent_works::skill::Skill;
212 use agent_works::skill::prompt_skill::PromptSkill;
213 let skill_dirs: Vec<PathBuf> = vec![
214 dirs_next().join(".claude").join("skills"),
216 PathBuf::from(".claude/skills"),
218 ];
219
220 for dir in &skill_dirs {
221 if dir.is_dir() {
222 match PromptSkill::scan_dir(dir) {
223 Ok(skills) => {
224 for skill in skills {
225 tracing::debug!(
226 name = skill.name(),
227 dir = %dir.display(),
228 "auto-loaded skill (prompt-injection mode)"
229 );
230 builder = builder.register_skill(skill);
231 }
232 },
233 Err(e) => {
234 tracing::warn!(dir = %dir.display(), error = %e, "failed to scan skills directory");
235 },
236 }
237 }
238 }
239 }
240
241 builder
242}
243
244#[cfg(feature = "skill")]
246fn dirs_next() -> std::path::PathBuf {
247 std::env::var("HOME")
248 .or_else(|_| std::env::var("USERPROFILE"))
249 .map(PathBuf::from)
250 .unwrap_or_else(|_| PathBuf::from("."))
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use async_trait::async_trait;
257 use futures_core::Stream;
258 use std::pin::Pin;
259 use std::task::{Context, Poll};
260
261 struct StubClient;
262 struct EmptyStream;
263
264 impl Stream for EmptyStream {
265 type Item = agent_base::AgentResult<agent_base::StreamChunk>;
266 fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
267 Poll::Ready(None)
268 }
269 }
270
271 #[async_trait]
272 impl agent_base::LlmClient for StubClient {
273 async fn chat(
274 &self,
275 _messages: &[agent_base::ChatMessage],
276 _tools: &[serde_json::Value],
277 _reasoning: Option<&agent_base::ReasoningConfig>,
278 _response_format: Option<&agent_base::ResponseFormat>,
279 ) -> agent_base::AgentResult<serde_json::Value> {
280 Ok(serde_json::json!({"choices":[{"message":{"content":"stub"}}]}))
281 }
282 async fn chat_stream(
283 &self,
284 _messages: &[agent_base::ChatMessage],
285 _tools: &[serde_json::Value],
286 _reasoning: Option<&agent_base::ReasoningConfig>,
287 _response_format: Option<&agent_base::ResponseFormat>,
288 ) -> agent_base::AgentResult<Pin<Box<dyn Stream<Item = agent_base::AgentResult<agent_base::StreamChunk>> + Send>>>
289 {
290 Ok(Box::pin(EmptyStream))
291 }
292 fn capabilities(&self) -> agent_base::LlmCapabilities {
293 agent_base::LlmCapabilities {
294 supports_streaming: true,
295 supports_tools: true,
296 supports_vision: false,
297 supports_thinking: true,
298 max_context_tokens: Some(128_000),
299 max_output_tokens: Some(16_384),
300 }
301 }
302 }
303
304 #[test]
305 fn test_max_tool_output_chars_default() {
306 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
307 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
308 let _ = builder;
309 }
310
311 #[test]
312 fn test_max_tool_output_chars_custom() {
313 unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "8000") };
314 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
315 let _ = builder;
316 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
317 }
318
319 #[test]
320 fn test_max_tool_output_chars_invalid_fallback() {
321 unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "not-a-number") };
322 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
323 let _ = builder;
324 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
325 }
326
327 #[cfg(feature = "multi-agent")]
329 #[tokio::test(flavor = "multi_thread")]
330 async fn test_base_agent_builder_registers_multi_agent_tools() {
331 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient))).system_prompt("test");
332
333 let runtime = builder.build().unwrap();
334
335 let tools = tokio::task::block_in_place(|| {
336 let tools = runtime.tools_mut();
337 let guard = tools.blocking_read();
338 guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
339 });
340
341 assert!(tools.contains(&"spawn_agent".to_string()), "expected spawn_agent tool");
342 assert!(tools.contains(&"send_message".to_string()), "expected send_message tool");
343 assert!(tools.contains(&"followup_task".to_string()), "expected followup_task tool");
344 assert!(tools.contains(&"wait_agent".to_string()), "expected wait_agent tool");
345 assert!(tools.contains(&"list_agents".to_string()), "expected list_agents tool");
346 assert!(tools.contains(&"close_agent".to_string()), "expected close_agent tool");
347 }
348
349 #[cfg(feature = "multi-agent")]
351 #[tokio::test(flavor = "multi_thread")]
352 async fn test_base_agent_builder_without_multi_agent() {
353 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)))
354 .system_prompt("test")
355 .without_multi_agent();
356
357 let runtime = builder.build().unwrap();
358
359 let tools = tokio::task::block_in_place(|| {
360 let tools = runtime.tools_mut();
361 let guard = tools.blocking_read();
362 guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
363 });
364
365 assert!(!tools.contains(&"spawn_agent".to_string()), "spawn_agent should not be registered");
366 assert!(!tools.contains(&"list_agents".to_string()), "list_agents should not be registered");
367 }
368}