1use crate::{
9 daemon::event::DaemonEventSender,
10 hook::{mcp::McpHandler, skill::SkillHandler, system::memory::Memory},
11};
12use std::{
13 collections::{BTreeMap, HashMap},
14 path::PathBuf,
15 sync::Arc,
16};
17use tokio::sync::{Mutex, broadcast, oneshot};
18use wcore::{
19 AgentConfig, AgentEvent, Hook, ToolRegistry,
20 model::Message,
21 protocol::message::{AgentEventKind, AgentEventMsg},
22};
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 cwd: std::path::PathBuf,
43 pub memory: Option<Memory>,
45 pub(crate) event_tx: DaemonEventSender,
47 pub(crate) scopes: BTreeMap<String, AgentScope>,
49 pub(crate) agent_descriptions: BTreeMap<String, String>,
51 events_tx: broadcast::Sender<AgentEventMsg>,
53 pub(crate) pending_asks: Arc<Mutex<HashMap<u64, oneshot::Sender<String>>>>,
55 pub(crate) session_cwds: Arc<Mutex<HashMap<u64, PathBuf>>>,
58}
59
60const BASE_TOOLS: &[&str] = &["bash", "ask_user"];
62
63const SKILL_TOOLS: &[&str] = &["skill"];
65
66const MCP_TOOLS: &[&str] = &["mcp"];
68
69const MEMORY_TOOLS: &[&str] = &["recall", "remember", "memory", "forget"];
71
72const TASK_TOOLS: &[&str] = &["delegate"];
74
75impl Hook for DaemonHook {
76 fn on_build_agent(&self, mut config: AgentConfig) -> AgentConfig {
77 config.system_prompt.push_str(&os::environment_block());
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 the mcp tool to list or call tools.",
97 names.join(", ")
98 ));
99 }
100 if let Ok(reg) = self.skills.registry.try_lock() {
101 let all_skills = reg.skills();
102 let visible: Vec<_> = if config.skills.is_empty() {
105 all_skills.iter().collect()
106 } else {
107 all_skills
108 .iter()
109 .filter(|s| config.skills.iter().any(|n| n == &s.name))
110 .collect()
111 };
112 if !visible.is_empty() {
113 let lines: Vec<String> = visible
114 .iter()
115 .map(|s| {
116 if s.description.is_empty() {
117 format!("- {}", s.name)
118 } else {
119 format!("- {}: {}", s.name, s.description)
120 }
121 })
122 .collect();
123 hints.push(format!(
124 "Skills:\n\
125 When a <skill> tag appears in a message, it has been pre-loaded by the system. \
126 Follow its instructions directly — do not announce or re-load it.\n\
127 Use the skill tool to discover available skills or load one by name.\n{}",
128 lines.join("\n")
129 ));
130 }
131 }
132 if !hints.is_empty() {
133 config.system_prompt.push_str(&format!(
134 "\n\n<resources>\n{}\n</resources>",
135 hints.join("\n")
136 ));
137 }
138
139 self.apply_scope(&mut config);
141 config
142 }
143
144 fn preprocess(&self, agent: &str, content: &str) -> String {
145 self.resolve_slash_skill(agent, content)
146 }
147
148 fn on_before_run(
149 &self,
150 agent: &str,
151 session_id: u64,
152 history: &[wcore::model::Message],
153 ) -> Vec<wcore::model::Message> {
154 let mut messages = Vec::new();
155 let has_members = self
157 .scopes
158 .get(agent)
159 .is_some_and(|s| !s.members.is_empty());
160 if has_members && !self.agent_descriptions.is_empty() {
161 let mut block = String::from("<agents>\n");
162 for (name, desc) in &self.agent_descriptions {
163 block.push_str(&format!("- {name}: {desc}\n"));
164 }
165 block.push_str("</agents>");
166 let mut msg = Message::user(block);
167 msg.auto_injected = true;
168 messages.push(msg);
169 }
170 if let Some(ref mem) = self.memory {
171 messages.extend(mem.before_run(history));
172 }
173 let cwd = self
175 .session_cwds
176 .try_lock()
177 .ok()
178 .and_then(|m| m.get(&session_id).cloned())
179 .unwrap_or_else(|| self.cwd.clone());
180 let mut cwd_msg = Message::user(format!(
181 "<environment>\nworking_directory: {}\n</environment>",
182 cwd.display()
183 ));
184 cwd_msg.auto_injected = true;
185 messages.push(cwd_msg);
186 messages
187 }
188
189 async fn on_register_tools(&self, tools: &mut ToolRegistry) {
190 self.mcp.register_tools(tools);
191 tools.insert_all(os::tool::tools());
192 tools.insert_all(skill::tool::tools());
193 tools.insert_all(system::task::tool::tools());
194 tools.insert_all(system::ask_user::tools());
195 if self.memory.is_some() {
196 tools.insert_all(system::memory::tool::tools());
197 }
198 }
199
200 fn on_after_compact(&self, agent: &str, summary: &str) {
201 if let Some(ref mem) = self.memory {
202 mem.after_compact(agent, summary);
203 }
204 }
205
206 fn on_event(&self, agent: &str, session_id: u64, event: &AgentEvent) {
207 let (kind, content) = match event {
208 AgentEvent::TextDelta(text) => {
209 tracing::trace!(%agent, text_len = text.len(), "agent text delta");
210 (AgentEventKind::TextDelta, String::new())
211 }
212 AgentEvent::ThinkingDelta(text) => {
213 tracing::trace!(%agent, text_len = text.len(), "agent thinking delta");
214 (AgentEventKind::ThinkingDelta, String::new())
215 }
216 AgentEvent::ToolCallsStart(calls) => {
217 tracing::debug!(%agent, count = calls.len(), "agent tool calls started");
218 let names: Vec<&str> = calls.iter().map(|c| c.function.name.as_str()).collect();
219 (AgentEventKind::ToolStart, names.join(", "))
220 }
221 AgentEvent::ToolResult { call_id, .. } => {
222 tracing::debug!(%agent, %call_id, "agent tool result");
223 (AgentEventKind::ToolResult, call_id.clone())
224 }
225 AgentEvent::ToolCallsComplete => {
226 tracing::debug!(%agent, "agent tool calls complete");
227 (AgentEventKind::ToolsComplete, String::new())
228 }
229 AgentEvent::Compact { summary } => {
230 tracing::info!(%agent, summary_len = summary.len(), "context compacted");
231 self.on_after_compact(agent, summary);
232 return;
233 }
234 AgentEvent::Done(response) => {
235 tracing::info!(
236 %agent,
237 iterations = response.iterations,
238 stop_reason = ?response.stop_reason,
239 "agent run complete"
240 );
241 (AgentEventKind::Done, String::new())
242 }
243 };
244 let _ = self.events_tx.send(AgentEventMsg {
245 agent: agent.to_string(),
246 session: session_id,
247 kind: kind.into(),
248 content,
249 });
250 }
251}
252
253impl DaemonHook {
254 pub fn new(
256 skills: SkillHandler,
257 mcp: McpHandler,
258 cwd: std::path::PathBuf,
259 memory: Option<Memory>,
260 event_tx: DaemonEventSender,
261 ) -> Self {
262 let (events_tx, _) = broadcast::channel(256);
263 Self {
264 skills,
265 mcp,
266 cwd,
267 memory,
268 event_tx,
269 scopes: BTreeMap::new(),
270 agent_descriptions: BTreeMap::new(),
271 events_tx,
272 pending_asks: Arc::new(Mutex::new(HashMap::new())),
273 session_cwds: Arc::new(Mutex::new(HashMap::new())),
274 }
275 }
276
277 pub fn subscribe_events(&self) -> broadcast::Receiver<AgentEventMsg> {
279 self.events_tx.subscribe()
280 }
281
282 pub(crate) fn register_scope(&mut self, name: String, config: &AgentConfig) {
284 if name != wcore::paths::DEFAULT_AGENT && !config.description.is_empty() {
285 self.agent_descriptions
286 .insert(name.clone(), config.description.clone());
287 }
288 self.scopes.insert(
289 name,
290 AgentScope {
291 tools: config.tools.clone(),
292 members: config.members.clone(),
293 skills: config.skills.clone(),
294 mcps: config.mcps.clone(),
295 },
296 );
297 }
298
299 fn apply_scope(&self, config: &mut AgentConfig) {
302 let has_scoping =
303 !config.skills.is_empty() || !config.mcps.is_empty() || !config.members.is_empty();
304 if !has_scoping {
305 return;
306 }
307
308 let mut whitelist: Vec<String> = BASE_TOOLS.iter().map(|&s| s.to_owned()).collect();
310 if self.memory.is_some() {
311 for &t in MEMORY_TOOLS {
312 whitelist.push(t.to_owned());
313 }
314 }
315 let mut scope_lines = Vec::new();
316
317 if !config.skills.is_empty() {
318 for &t in SKILL_TOOLS {
319 whitelist.push(t.to_owned());
320 }
321 scope_lines.push(format!("skills: {}", config.skills.join(", ")));
322 }
323
324 if !config.mcps.is_empty() {
325 for &t in MCP_TOOLS {
326 whitelist.push(t.to_owned());
327 }
328 let server_names: Vec<&str> = config.mcps.iter().map(|s| s.as_str()).collect();
329 scope_lines.push(format!("mcp servers: {}", server_names.join(", ")));
330 }
331
332 if !config.members.is_empty() {
333 for &t in TASK_TOOLS {
334 whitelist.push(t.to_owned());
335 }
336 scope_lines.push(format!("members: {}", config.members.join(", ")));
337 }
338
339 if !scope_lines.is_empty() {
340 let scope_block = format!("\n\n<scope>\n{}\n</scope>", scope_lines.join("\n"));
341 config.system_prompt.push_str(&scope_block);
342 }
343
344 config.tools = whitelist;
345 }
346
347 fn resolve_slash_skill(&self, agent: &str, content: &str) -> String {
353 let trimmed = content.trim_start();
354 let Some(rest) = trimmed.strip_prefix('/') else {
355 return content.to_owned();
356 };
357
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 let remainder = &rest[end..];
364
365 if name.is_empty() || name.contains("..") {
366 return content.to_owned();
367 }
368
369 if let Some(scope) = self.scopes.get(agent)
371 && !scope.skills.is_empty()
372 && !scope.skills.iter().any(|s| s == name)
373 {
374 return content.to_owned();
375 }
376
377 for dir in &self.skills.skill_dirs {
379 let skill_file = dir.join(name).join("SKILL.md");
380 let Ok(file_content) = std::fs::read_to_string(&skill_file) else {
381 continue;
382 };
383 let Ok(skill) = skill::loader::parse_skill_md(&file_content) else {
384 continue;
385 };
386 let body = remainder.trim_start();
388 let block = format!("<skill name=\"{name}\">\n{}\n</skill>", skill.body);
389 return if body.is_empty() {
390 block
391 } else {
392 format!("{body}\n\n{block}")
393 };
394 }
395
396 content.to_owned()
397 }
398
399 pub async fn dispatch_tool(
404 &self,
405 name: &str,
406 args: &str,
407 agent: &str,
408 _sender: &str,
409 session_id: Option<u64>,
410 ) -> String {
411 if let Some(scope) = self.scopes.get(agent)
413 && !scope.tools.is_empty()
414 && !scope.tools.iter().any(|t| t.as_str() == name)
415 {
416 return format!("tool not available: {name}");
417 }
418 match name {
419 "mcp" => self.dispatch_mcp(args, agent).await,
420 "skill" => self.dispatch_skill(args, agent).await,
421 "bash" => self.dispatch_bash(args, session_id).await,
422 "delegate" => self.dispatch_delegate(args, agent).await,
423 "recall" => self.dispatch_recall(args).await,
424 "remember" => self.dispatch_remember(args).await,
425 "memory" => self.dispatch_memory(args).await,
426 "forget" => self.dispatch_forget(args).await,
427 "ask_user" => self.dispatch_ask_user(args, session_id).await,
428 name => format!("tool not available: {name}"),
429 }
430 }
431}