1use crate::{
10 daemon::event::DaemonEventSender,
11 ext::hub::DownloadRegistry,
12 hook::{
13 mcp::McpHandler,
14 os::PermissionConfig,
15 skill::SkillHandler,
16 system::{memory::Memory, task::TaskSet},
17 },
18 service::ServiceRegistry,
19};
20use std::{collections::BTreeMap, sync::Arc};
21use tokio::sync::Mutex;
22use wcore::{AgentConfig, AgentEvent, Hook, ToolRegistry, model::Message};
23
24pub mod mcp;
25pub mod os;
26pub mod skill;
27pub mod system;
28
29#[derive(Default)]
31pub(crate) struct AgentScope {
32 pub(crate) tools: Vec<String>,
33 pub(crate) members: Vec<String>,
34 pub(crate) skills: Vec<String>,
35 pub(crate) mcps: Vec<String>,
36}
37
38pub struct DaemonHook {
39 pub skills: SkillHandler,
40 pub mcp: McpHandler,
41 pub tasks: Arc<Mutex<TaskSet>>,
42 pub downloads: Arc<Mutex<DownloadRegistry>>,
43 pub permissions: PermissionConfig,
44 pub sandboxed: bool,
46 pub memory: Option<Memory>,
48 pub(crate) event_tx: DaemonEventSender,
50 pub(crate) scopes: BTreeMap<String, AgentScope>,
52 pub(crate) agent_descriptions: BTreeMap<String, String>,
54 pub(crate) registry: Option<Arc<ServiceRegistry>>,
56}
57
58const BASE_TOOLS: &[&str] = &["bash"];
61
62const SKILL_TOOLS: &[&str] = &["search_skill", "load_skill", "save_skill"];
64
65const MCP_TOOLS: &[&str] = &["search_mcp", "call_mcp_tool"];
67
68const MEMORY_TOOLS: &[&str] = &["recall", "remember", "memory", "forget", "soul"];
70
71const TASK_TOOLS: &[&str] = &["delegate", "collect", "check_tasks"];
73
74impl Hook for DaemonHook {
75 fn on_build_agent(&self, mut config: AgentConfig) -> AgentConfig {
76 config
78 .system_prompt
79 .push_str(&os::environment_block(self.sandboxed));
80
81 if let Some(ref mem) = self.memory {
83 let prompt = mem.build_prompt();
84 if !prompt.is_empty() {
85 config.system_prompt.push_str(&prompt);
86 }
87 }
88
89 let mut hints = Vec::new();
92 let mcp_servers = self.mcp.cached_list();
93 if !mcp_servers.is_empty() {
94 let names: Vec<&str> = mcp_servers.iter().map(|(n, _)| n.as_str()).collect();
95 hints.push(format!(
96 "MCP servers: {}. Use search_mcp to list tools, call_mcp_tool to invoke them.",
97 names.join(", ")
98 ));
99 }
100 if let Ok(reg) = self.skills.registry.try_lock() {
101 let skills: Vec<&str> = reg.skills().iter().map(|s| s.name.as_str()).collect();
102 if !skills.is_empty() {
103 hints.push(format!(
104 "Skills: {}. Use search_skill to find skills, load_skill to activate one.",
105 skills.join(", ")
106 ));
107 }
108 }
109 if !hints.is_empty() {
110 config.system_prompt.push_str(&format!(
111 "\n\n<resources>\n{}\n</resources>",
112 hints.join("\n")
113 ));
114 }
115
116 self.apply_scope(&mut config);
118 config
119 }
120
121 fn preprocess(&self, agent: &str, content: &str) -> String {
122 self.resolve_slash_skill(agent, content)
123 }
124
125 fn on_before_run(
126 &self,
127 agent: &str,
128 history: &[wcore::model::Message],
129 ) -> Vec<wcore::model::Message> {
130 let mut messages = Vec::new();
131 let has_members = self
133 .scopes
134 .get(agent)
135 .is_some_and(|s| !s.members.is_empty());
136 if has_members && !self.agent_descriptions.is_empty() {
137 let mut block = String::from("<agents>\n");
138 for (name, desc) in &self.agent_descriptions {
139 block.push_str(&format!("- {name}: {desc}\n"));
140 }
141 block.push_str("</agents>");
142 let mut msg = Message::user(block);
143 msg.auto_injected = true;
144 messages.push(msg);
145 }
146 if let Some(ref mem) = self.memory {
147 messages.extend(mem.before_run(history));
148 }
149 messages
150 }
151
152 async fn on_register_tools(&self, tools: &mut ToolRegistry) {
153 self.mcp.register_tools(tools);
154 tools.insert_all(os::tool::tools());
155 tools.insert_all(skill::tool::tools());
156 tools.insert_all(system::task::tool::tools());
157 if let Some(ref registry) = self.registry {
158 registry.register_tools(tools).await;
159 }
160 if self.memory.is_some() {
161 tools.insert_all(system::memory::tool::tools());
162 }
163 }
164
165 fn on_after_compact(&self, agent: &str, summary: &str) {
166 if let Some(ref mem) = self.memory {
167 mem.after_compact(agent, summary);
168 }
169 }
170
171 fn on_event(&self, agent: &str, event: &AgentEvent) {
172 match event {
173 AgentEvent::TextDelta(text) => {
174 tracing::trace!(%agent, text_len = text.len(), "agent text delta");
175 }
176 AgentEvent::ThinkingDelta(text) => {
177 tracing::trace!(%agent, text_len = text.len(), "agent thinking delta");
178 }
179 AgentEvent::ToolCallsStart(calls) => {
180 tracing::debug!(%agent, count = calls.len(), "agent tool calls started");
181 }
182 AgentEvent::ToolResult { call_id, .. } => {
183 tracing::debug!(%agent, %call_id, "agent tool result");
184 }
185 AgentEvent::ToolCallsComplete => {
186 tracing::debug!(%agent, "agent tool calls complete");
187 }
188 AgentEvent::Compact { summary } => {
189 tracing::info!(%agent, summary_len = summary.len(), "context compacted");
190 self.on_after_compact(agent, summary);
191 }
192 AgentEvent::Done(response) => {
193 tracing::info!(
194 %agent,
195 iterations = response.iterations,
196 stop_reason = ?response.stop_reason,
197 "agent run complete"
198 );
199 }
200 }
201 }
202}
203
204impl DaemonHook {
205 #[allow(clippy::too_many_arguments)]
207 pub fn new(
208 skills: SkillHandler,
209 mcp: McpHandler,
210 tasks: Arc<Mutex<TaskSet>>,
211 downloads: Arc<Mutex<DownloadRegistry>>,
212 permissions: PermissionConfig,
213 sandboxed: bool,
214 memory: Option<Memory>,
215 registry: Option<Arc<ServiceRegistry>>,
216 event_tx: DaemonEventSender,
217 ) -> Self {
218 Self {
219 skills,
220 mcp,
221 tasks,
222 downloads,
223 permissions,
224 sandboxed,
225 memory,
226 event_tx,
227 scopes: BTreeMap::new(),
228 agent_descriptions: BTreeMap::new(),
229 registry,
230 }
231 }
232
233 pub(crate) fn register_scope(&mut self, name: String, config: &AgentConfig) {
235 if name != wcore::paths::DEFAULT_AGENT && !config.description.is_empty() {
236 self.agent_descriptions
237 .insert(name.clone(), config.description.clone());
238 }
239 self.scopes.insert(
240 name,
241 AgentScope {
242 tools: config.tools.clone(),
243 members: config.members.clone(),
244 skills: config.skills.clone(),
245 mcps: config.mcps.clone(),
246 },
247 );
248 }
249
250 fn apply_scope(&self, config: &mut AgentConfig) {
253 let has_scoping =
254 !config.skills.is_empty() || !config.mcps.is_empty() || !config.members.is_empty();
255 if !has_scoping {
256 return;
257 }
258
259 let mut whitelist: Vec<String> = BASE_TOOLS.iter().map(|&s| s.to_owned()).collect();
261 if self.memory.is_some() {
262 for &t in MEMORY_TOOLS {
263 whitelist.push(t.to_owned());
264 }
265 }
266 if let Some(ref registry) = self.registry {
267 for tool_name in registry.tools.keys() {
268 whitelist.push(tool_name.clone());
269 }
270 }
271 let mut scope_lines = Vec::new();
272
273 if !config.skills.is_empty() {
274 for &t in SKILL_TOOLS {
275 whitelist.push(t.to_owned());
276 }
277 scope_lines.push(format!("skills: {}", config.skills.join(", ")));
278 }
279
280 if !config.mcps.is_empty() {
281 for &t in MCP_TOOLS {
282 whitelist.push(t.to_owned());
283 }
284 let server_names: Vec<&str> = config.mcps.iter().map(|s| s.as_str()).collect();
285 scope_lines.push(format!(
286 "mcp servers: {}\nUse search_mcp to discover tools, call_mcp_tool to invoke them.",
287 server_names.join(", ")
288 ));
289 }
290
291 if !config.members.is_empty() {
292 for &t in TASK_TOOLS {
293 whitelist.push(t.to_owned());
294 }
295 scope_lines.push(format!("members: {}", config.members.join(", ")));
296 }
297
298 if !scope_lines.is_empty() {
299 let scope_block = format!("\n\n<scope>\n{}\n</scope>", scope_lines.join("\n"));
300 config.system_prompt.push_str(&scope_block);
301 }
302
303 config.tools = whitelist;
304 }
305
306 fn check_perm(&self, name: &str, agent: &str, sender: &str) -> Option<String> {
312 if self.sandboxed && BASE_TOOLS.contains(&name) {
314 return None;
315 }
316 use crate::hook::os::ToolPermission;
317 match self.permissions.resolve(agent, name) {
318 ToolPermission::Allow => None,
319 ToolPermission::Deny => Some(format!("permission denied: {name}")),
320 ToolPermission::Ask => {
321 let interactive = sender.is_empty() || sender == "user";
322 if interactive {
323 None
324 } else {
325 tracing::warn!(
326 tool = name,
327 agent = agent,
328 sender = sender,
329 "tool requires approval — denied for non-interactive session"
330 );
331 Some(format!(
332 "permission denied: {name} (requires interactive approval)"
333 ))
334 }
335 }
336 }
337 }
338
339 async fn dispatch_external(&self, name: &str, args: &str, agent: &str) -> Option<String> {
342 self.registry
343 .as_ref()?
344 .dispatch_tool(name, args, agent, None)
345 .await
346 }
347
348 fn resolve_slash_skill(&self, agent: &str, content: &str) -> String {
352 let scope = self.scopes.get(agent);
353 let mut appended = Vec::new();
354 let mut rest = content;
355
356 while let Some(slash) = rest.find('/') {
357 rest = &rest[slash + 1..];
358 let end = rest
360 .find(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-')
361 .unwrap_or(rest.len());
362 let name = &rest[..end];
363 rest = &rest[end..];
364
365 if name.is_empty() || name.contains("..") {
366 continue;
367 }
368 if let Some(scope) = scope
370 && !scope.skills.is_empty()
371 && !scope.skills.iter().any(|s| s == name)
372 {
373 continue;
374 }
375 let skill_file = self.skills.skills_dir.join(name).join("SKILL.md");
376 let Ok(file_content) = std::fs::read_to_string(&skill_file) else {
377 continue;
378 };
379 let Ok(skill) = skill::loader::parse_skill_md(&file_content) else {
380 continue;
381 };
382 appended.push(skill.body);
383 }
384
385 if appended.is_empty() {
386 return content.to_owned();
387 }
388 format!("{}\n\n{}", content, appended.join("\n\n"))
389 }
390
391 pub async fn dispatch_tool(&self, name: &str, args: &str, agent: &str, sender: &str) -> String {
397 if let Some(denied) = self.check_perm(name, agent, sender) {
398 return denied;
399 }
400 if let Some(scope) = self.scopes.get(agent)
402 && !scope.tools.is_empty()
403 && !scope.tools.iter().any(|t| t.as_str() == name)
404 {
405 return format!("tool not available: {name}");
406 }
407 match name {
408 "search_mcp" => self.dispatch_search_mcp(args, agent).await,
409 "call_mcp_tool" => self.dispatch_call_mcp_tool(args, agent).await,
410 "search_skill" => self.dispatch_search_skill(args, agent).await,
411 "load_skill" => self.dispatch_load_skill(args, agent).await,
412 "save_skill" => self.dispatch_save_skill(args).await,
413 "bash" => self.dispatch_bash(args).await,
414 "delegate" => self.dispatch_delegate(args, agent).await,
415 "collect" => self.dispatch_collect(args).await,
416 "check_tasks" => self.dispatch_check_tasks(args).await,
417 "recall" => self.dispatch_recall(args).await,
418 "remember" => self.dispatch_remember(args).await,
419 "memory" => self.dispatch_memory(args).await,
420 "forget" => self.dispatch_forget(args).await,
421 "soul" => self.dispatch_soul(args).await,
422 name => {
424 if let Some(result) = self.dispatch_external(name, args, agent).await {
425 return result;
426 }
427 format!("tool not available: {name}")
428 }
429 }
430 }
431}