phi_agent/agent/
builder.rs1#[cfg(feature = "file")]
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use agent_base::{ConsecutiveFailureRecovery, Language, ReasoningConfig, ReasoningEffort, UpdatePlanTool};
12
13use crate::agent::compression::SummarizingMiddleware;
14
15#[allow(unused_mut)]
42pub fn base_agent_builder(llm_client: Arc<dyn agent_base::StreamClient>) -> agent_works::AgentBuilder {
43 base_agent_builder_with_excludes(llm_client, Vec::new())
44}
45
46#[allow(unused_mut)]
51pub fn base_agent_builder_with_excludes(
52 llm_client: Arc<dyn agent_base::StreamClient>,
53 file_excludes: Vec<String>,
54) -> agent_works::AgentBuilder {
55 let max_tool_output_chars = match std::env::var("PHI_MAX_TOOL_OUTPUT_CHARS") {
61 Ok(value) => match value.trim().parse::<usize>() {
62 Ok(n) => n,
63 Err(_) => {
64 tracing::warn!(
65 value = %value,
66 "PHI_MAX_TOOL_OUTPUT_CHARS is not a valid integer; falling back to default 4000"
67 );
68 4000
69 },
70 },
71 Err(_) => 4000,
72 };
73
74 #[cfg(feature = "file")]
75 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
76
77 let mut builder = agent_works::AgentBuilder::new(llm_client.clone())
78 .language(Language::En)
79 .reasoning(ReasoningConfig { effort: Some(ReasoningEffort::Medium), ..Default::default() })
80 .enable_thought(true)
81 .enable_thinking(true)
82 .max_sessions(50)
83 .max_turns_per_session(100)
84 .execution_max_turns(200)
85 .max_message_tokens(50_000)
86 .max_tool_output_chars(max_tool_output_chars)
87 .error_recovery(Arc::new(ConsecutiveFailureRecovery::new(3)))
88 .middleware(SummarizingMiddleware::new(llm_client));
92
93 #[cfg(feature = "file")]
95 {
96 use phi_kernel_tools::file::{EditFileTool, ListFilesTool, ReadFileTool, WriteFileTool};
97 builder = builder
98 .register_tool_arc(Arc::new(ReadFileTool::new(cwd.clone())))
99 .register_tool_arc(Arc::new(WriteFileTool::new(cwd.clone())))
100 .register_tool_arc(Arc::new(EditFileTool::new(cwd.clone())))
101 .register_tool_arc(Arc::new(ListFilesTool::with_excludes(cwd.clone(), file_excludes)));
102 }
103
104 builder = builder.register_tool_arc(Arc::new(UpdatePlanTool::new()));
109
110 #[cfg(feature = "multi-agent")]
112 {
113 use agent_works::multi_agent::MultiAgentConfig;
114 builder =
115 builder.with_multi_agent(MultiAgentConfig::default()).with_multi_agent_tool_factory(Arc::new(|runtime| {
116 phi_kernel_tools::multi_agent::create_all_tools(runtime)
117 }));
118 }
119
120 #[cfg(feature = "file")]
122 {
123 use agent_works::skill::Skill;
124 use agent_works::skill::prompt_skill::PromptSkill;
125 let skill_dirs: Vec<PathBuf> = vec![
126 dirs_next().join(".claude").join("skills"),
128 PathBuf::from(".claude/skills"),
130 ];
131
132 for dir in &skill_dirs {
133 if dir.is_dir() {
134 match PromptSkill::scan_dir(dir) {
135 Ok(skills) => {
136 for skill in skills {
137 tracing::debug!(
138 name = skill.name(),
139 dir = %dir.display(),
140 "auto-loaded skill (prompt-injection mode)"
141 );
142 builder = builder.register_skill(skill);
143 }
144 },
145 Err(e) => {
146 tracing::warn!(dir = %dir.display(), error = %e, "failed to scan skills directory");
147 },
148 }
149 }
150 }
151 }
152
153 builder
154}
155
156#[cfg(feature = "file")]
158fn dirs_next() -> std::path::PathBuf {
159 std::env::var("HOME")
160 .or_else(|_| std::env::var("USERPROFILE"))
161 .map(PathBuf::from)
162 .unwrap_or_else(|_| PathBuf::from("."))
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use async_trait::async_trait;
169 use futures_core::Stream;
170 use std::pin::Pin;
171 use std::task::{Context, Poll};
172
173 struct StubClient;
174 struct EmptyStream;
175
176 impl Stream for EmptyStream {
177 type Item = agent_base::AgentResult<agent_base::StreamChunk>;
178 fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
179 Poll::Ready(None)
180 }
181 }
182
183 #[async_trait]
184 impl agent_base::LlmClient for StubClient {
185 async fn chat(
186 &self,
187 _messages: &[agent_base::ChatMessage],
188 _tools: &[serde_json::Value],
189 _reasoning: Option<&agent_base::ReasoningConfig>,
190 _response_format: Option<&agent_base::ResponseFormat>,
191 ) -> agent_base::AgentResult<serde_json::Value> {
192 Ok(serde_json::json!({"choices":[{"message":{"content":"stub"}}]}))
193 }
194 async fn chat_stream(
195 &self,
196 _messages: &[agent_base::ChatMessage],
197 _tools: &[serde_json::Value],
198 _reasoning: Option<&agent_base::ReasoningConfig>,
199 _response_format: Option<&agent_base::ResponseFormat>,
200 ) -> agent_base::AgentResult<Pin<Box<dyn Stream<Item = agent_base::AgentResult<agent_base::StreamChunk>> + Send>>>
201 {
202 Ok(Box::pin(EmptyStream))
203 }
204 fn capabilities(&self) -> agent_base::LlmCapabilities {
205 agent_base::LlmCapabilities {
206 supports_streaming: true,
207 supports_tools: true,
208 supports_vision: false,
209 supports_thinking: true,
210 max_context_tokens: Some(128_000),
211 max_output_tokens: Some(16_384),
212 }
213 }
214 }
215
216 #[test]
217 fn test_max_tool_output_chars_default() {
218 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
219 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
220 let _ = builder;
221 }
222
223 #[test]
224 fn test_max_tool_output_chars_custom() {
225 unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "8000") };
226 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
227 let _ = builder;
228 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
229 }
230
231 #[test]
232 fn test_max_tool_output_chars_invalid_fallback() {
233 unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "not-a-number") };
234 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
235 let _ = builder;
236 unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
237 }
238
239 #[cfg(feature = "multi-agent")]
241 #[tokio::test(flavor = "multi_thread")]
242 async fn test_base_agent_builder_registers_multi_agent_tools() {
243 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient))).system_prompt("test");
244
245 let runtime = builder.build().unwrap();
246
247 let tools = tokio::task::block_in_place(|| {
248 let tools = runtime.tools_mut();
249 let guard = tools.blocking_read();
250 guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
251 });
252
253 assert!(tools.contains(&"spawn_agent".to_string()), "expected spawn_agent tool");
254 assert!(tools.contains(&"send_message".to_string()), "expected send_message tool");
255 assert!(tools.contains(&"followup_task".to_string()), "expected followup_task tool");
256 assert!(tools.contains(&"wait_agent".to_string()), "expected wait_agent tool");
257 assert!(tools.contains(&"list_agents".to_string()), "expected list_agents tool");
258 assert!(tools.contains(&"close_agent".to_string()), "expected close_agent tool");
259 }
260
261 #[cfg(feature = "multi-agent")]
263 #[tokio::test(flavor = "multi_thread")]
264 async fn test_base_agent_builder_without_multi_agent() {
265 let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)))
266 .system_prompt("test")
267 .without_multi_agent();
268
269 let runtime = builder.build().unwrap();
270
271 let tools = tokio::task::block_in_place(|| {
272 let tools = runtime.tools_mut();
273 let guard = tools.blocking_read();
274 guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
275 });
276
277 assert!(!tools.contains(&"spawn_agent".to_string()), "spawn_agent should not be registered");
278 assert!(!tools.contains(&"list_agents".to_string()), "list_agents should not be registered");
279 }
280}