Skip to main content

pi/core/agent_session/
tools.rs

1//! Tool registry / activation impls.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/agent-session.ts`
4//! `getActiveToolNames`, `getAllTools`, `getToolDefinition`,
5//! `setActiveToolsByName`, `_refreshToolRegistry`, and the tool-registry half
6//! of `_buildRuntime`.
7//!
8//! Behaviour preserved from the TypeScript contract:
9//! - Active tools are the live agent tool list; the registry is the superset
10//!   of built-in + extension + SDK custom tools filtered by the allow /
11//!   exclude lists.
12//! - The registry preserves insertion order: built-ins first (in the order
13//!   the constructor received them), then extension tools in registration
14//!   order (first-wins on duplicate names). TypeScript `Map` iteration order
15//!   is preserved using a `Vec`-backed registry.
16//! - `set_active_tools_by_name` ignores unknown names, preserves order, and
17//!   caches the validated names on `AgentSessionInner`.
18//!
19//! System-prompt rebuild: TypeScript `setActiveToolsByName` also calls
20//! `_rebuildSystemPrompt(validToolNames)`. That rebuild needs context files,
21//! skills, appends, and snippets owned by the (forthcoming) `system_prompt`
22//! slice. This module deliberately does **not** mutate the system prompt; the
23//! `system_prompt` slice installs the rebuild hook when it lands, preserving
24//! the strict module ownership required by the foundation.
25//!
26//! `parse_skill_block` mirrors TypeScript `parseSkillBlock` so the
27//! interactive mode and HTML / JSONL exporters can decode `<skill>` blocks
28//! emitted by `expand_skill_invocation`.
29
30use std::collections::HashSet;
31use std::sync::Arc;
32
33use pi_agent::AgentTool;
34
35use super::AgentSession;
36
37/// Public tool metadata returned by [`AgentSession::get_all_tools`].
38#[derive(Clone, Debug, PartialEq)]
39pub struct ToolInfo {
40    /// Registered tool name.
41    pub name: String,
42    /// Human-readable description.
43    pub description: String,
44    /// JSON Schema for arguments.
45    pub parameters: serde_json::Value,
46}
47
48/// Inputs for [`AgentSession::refresh_tool_registry`].
49#[derive(Clone, Debug, Default)]
50pub struct RefreshToolRegistryOptions {
51    /// Override the active tool list (otherwise reuse the previous active
52    /// list). Unknown names are dropped during `set_active_tools_by_name`.
53    pub active_tool_names: Option<Vec<String>>,
54    /// When `true`, all extension tools are appended to the active list on
55    /// first build (TypeScript `includeAllExtensionTools`).
56    pub include_all_extension_tools: bool,
57}
58
59impl AgentSession {
60    /// Build the initial tool registry from the configured base tools and
61    /// install the requested active set.
62    ///
63    /// Called once from [`super::AgentSession::new`]. Subsequent reloads go
64    /// through [`Self::refresh_tool_registry`].
65    pub(super) fn build_initial_tool_registry(
66        &self,
67        base_tools: Vec<Arc<dyn AgentTool>>,
68        initial_active: Option<Vec<String>>,
69        allowed: Option<Vec<String>>,
70        excluded: Option<Vec<String>>,
71    ) {
72        {
73            let mut inner = self.lock_inner();
74            inner.allowed_tool_names = allowed.map(|names| names.into_iter().collect());
75            inner.excluded_tool_names = excluded.map(|names| names.into_iter().collect());
76            inner.base_tool_definitions = build_base_definitions(base_tools);
77            inner.tool_registry.clear();
78        }
79        let opts = RefreshToolRegistryOptions {
80            active_tool_names: initial_active,
81            include_all_extension_tools: true,
82        };
83        self.refresh_tool_registry(&opts);
84    }
85
86    /// Refresh the tool registry from the current extension snapshot.
87    ///
88    /// Rebuilds:
89    /// - `tool_registry`: built-in + extension + SDK custom tools (insertion
90    ///   ordered, first-wins on duplicate names), filtered by allow/exclude.
91    /// - `active_tool_names`: previous active list plus any newly-registered
92    ///   allow/extension tools, filtered through the new registry.
93    ///
94    /// Finally calls [`Self::set_active_tools_by_name`] to apply the result to
95    /// the agent state.
96    pub fn refresh_tool_registry(&self, options: &RefreshToolRegistryOptions) {
97        let runner = self.hooks.runner();
98        let extension_tools = runner.get_all_registered_tools();
99        let (base_definitions, allowed, excluded, previous_active, previous_registry_names) = {
100            let inner = self.lock_inner();
101            (
102                inner.base_tool_definitions.clone(),
103                inner.allowed_tool_names.clone(),
104                inner.excluded_tool_names.clone(),
105                self.agent_state_tool_names(),
106                inner
107                    .tool_registry
108                    .iter()
109                    .map(|entry| entry.name().to_owned())
110                    .collect::<HashSet<String>>(),
111            )
112        };
113        let is_allowed = |name: &str| {
114            allowed.as_ref().is_none_or(|set| set.contains(name))
115                && !excluded.as_ref().is_some_and(|set| set.contains(name))
116        };
117
118        // Built-in registry filtered by allow/exclude, preserving base order.
119        let mut registry: Vec<Arc<dyn AgentTool>> =
120            Vec::with_capacity(base_definitions.len().saturating_add(extension_tools.len()));
121        let mut seen: HashSet<String> = HashSet::new();
122        for tool in &base_definitions {
123            let name = tool.name();
124            if is_allowed(name) && seen.insert(name.to_owned()) {
125                registry.push(Arc::clone(tool));
126            }
127        }
128        // Extension + custom tools filtered by allow/exclude. First
129        // registration wins for duplicate names; built-ins already in `seen`
130        // take precedence. Extension tool ordering follows sorted name order
131        // (HashMap iter is non-deterministic) so the wire ordering is stable.
132        let mut extension_pairs: Vec<(String, Arc<dyn AgentTool>)> = extension_tools
133            .into_iter()
134            .filter(|(name, _)| is_allowed(name))
135            .collect();
136        extension_pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
137        for (name, tool) in extension_pairs {
138            if seen.insert(name.clone()) {
139                registry.push(tool);
140            }
141        }
142
143        // Compute the next active list.
144        let mut next_active: Vec<String> = match &options.active_tool_names {
145            Some(names) => names.clone(),
146            None => previous_active.clone(),
147        };
148        next_active.retain(|name| is_allowed(name));
149        if let Some(allowed_set) = &allowed {
150            for entry in &registry {
151                if allowed_set.contains(entry.name()) {
152                    next_active.push(entry.name().to_owned());
153                }
154            }
155        } else if options.include_all_extension_tools {
156            for entry in &registry {
157                let name = entry.name();
158                if !base_definitions.iter().any(|base| base.name() == name) {
159                    next_active.push(name.to_owned());
160                }
161            }
162        } else if options.active_tool_names.is_none() {
163            for entry in &registry {
164                let name = entry.name();
165                if !previous_registry_names.contains(name) {
166                    next_active.push(name.to_owned());
167                }
168            }
169        }
170
171        // Commit the registry before applying the active list so the
172        // validation in `set_active_tools_by_name` can find every name.
173        {
174            let mut inner = self.lock_inner();
175            inner.tool_registry = registry;
176        }
177        let deduped = dedup_preserve_order(next_active);
178        self.set_active_tools_by_name(deduped);
179    }
180
181    /// Active tool names — the live agent tool list in registration order.
182    #[must_use]
183    pub fn get_active_tool_names(&self) -> Vec<String> {
184        self.agent_state_tool_names()
185    }
186
187    /// All configured tools with name / description / parameter schema.
188    ///
189    /// Order matches the registry insertion order: built-ins first, then
190    /// extension tools in alphabetical name order.
191    #[must_use]
192    pub fn get_all_tools(&self) -> Vec<ToolInfo> {
193        self.lock_inner()
194            .tool_registry
195            .iter()
196            .map(|tool| ToolInfo {
197                name: tool.name().to_owned(),
198                description: tool.description().to_owned(),
199                parameters: tool.parameters().clone(),
200            })
201            .collect()
202    }
203
204    /// Look up a tool by name.
205    #[must_use]
206    pub fn get_tool(&self, name: &str) -> Option<Arc<dyn AgentTool>> {
207        self.lock_inner()
208            .tool_registry
209            .iter()
210            .find(|tool| tool.name() == name)
211            .cloned()
212    }
213
214    /// Set the active tool set by name.
215    ///
216    /// Unknown names are ignored. Order is preserved. The agent tool list,
217    /// the prepare-next-turn hook snapshot, and the cached active names are
218    /// refreshed in lockstep.
219    ///
220    /// **System-prompt rebuild boundary**: the TypeScript reference also calls
221    /// `_rebuildSystemPrompt(validToolNames)`. That rebuild needs context
222    /// files, skills, and snippets owned by the `system_prompt` slice.
223    pub fn set_active_tools_by_name(&self, tool_names: Vec<String>) {
224        let registry = self.lock_inner().tool_registry.clone();
225        let lookup = |name: &str| -> Option<Arc<dyn AgentTool>> {
226            registry.iter().find(|tool| tool.name() == name).cloned()
227        };
228        let mut tools: Vec<Arc<dyn AgentTool>> = Vec::with_capacity(tool_names.len());
229        let mut valid_names: Vec<String> = Vec::with_capacity(tool_names.len());
230        let mut seen: HashSet<String> = HashSet::new();
231        for name in tool_names {
232            if seen.contains(&name) {
233                continue;
234            }
235            if let Some(tool) = lookup(&name) {
236                tools.push(tool);
237                valid_names.push(name.clone());
238                seen.insert(name);
239            }
240        }
241        // Keep the hook snapshot synchronized so prepare_next_turn cannot
242        // reinstall stale construction-time tools.
243        self.hooks.set_tools(tools.clone());
244        self.agent.set_tools(tools);
245        {
246            let mut inner = self.lock_inner();
247            inner.active_tool_names = valid_names;
248        }
249    }
250
251    /// Read the live agent tool names without holding `inner`.
252    fn agent_state_tool_names(&self) -> Vec<String> {
253        self.agent
254            .state()
255            .tools
256            .iter()
257            .map(|tool| tool.name().to_owned())
258            .collect()
259    }
260}
261
262// ---------------------------------------------------------------------------
263// Skill block parsing
264// ---------------------------------------------------------------------------
265
266/// Parsed `<skill>` block extracted from user message text.
267///
268/// Mirrors TypeScript `ParsedSkillBlock`. Inverse of
269/// [`crate::core::resources::skills::expand_skill_invocation`].
270#[derive(Clone, Debug, PartialEq, Eq)]
271pub struct ParsedSkillBlock {
272    /// Skill name (`name="…"` attribute).
273    pub name: String,
274    /// Skill file path (`location="…"` attribute).
275    pub location: String,
276    /// Skill body between the open and close tags.
277    pub content: String,
278    /// Trailing user message after the block (`\n\n…`), trimmed.
279    pub user_message: Option<String>,
280}
281
282/// Parse a `<skill name="…" location="…">…</skill>` block from message text.
283///
284/// Returns `None` when `text` does not start with a skill block. Mirrors the
285/// TypeScript regex
286/// `/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/`.
287#[must_use]
288pub fn parse_skill_block(text: &str) -> Option<ParsedSkillBlock> {
289    let prefix = "<skill name=\"";
290    let after_name_open = text.strip_prefix(prefix)?;
291    // The name attribute ends at `" location="`.
292    let name_attr_close = "\" location=\"";
293    let name_end = after_name_open.find(name_attr_close)?;
294    let name = &after_name_open[..name_end];
295    let after_location_open = &after_name_open[name_end + name_attr_close.len()..];
296    // The location attribute ends at `">`.
297    let location_end = after_location_open.find("\">")?;
298    let location = &after_location_open[..location_end];
299    let after_open_tag = &after_location_open[location_end + "\">".len()..];
300    // Body must start with `\n` and end with `\n</skill>`.
301    let body = after_open_tag.strip_prefix('\n')?;
302    let close_tag = "\n</skill>";
303    let body_end = body.find(close_tag)?;
304    let content = &body[..body_end];
305    let trailing = &body[body_end + close_tag.len()..];
306    let user_message = match trailing.strip_prefix("\n\n") {
307        Some(rest) => {
308            let trimmed = rest.trim();
309            if trimmed.is_empty() {
310                None
311            } else {
312                Some(trimmed.to_owned())
313            }
314        }
315        None => {
316            // The regex anchors on `$`; any other trailing content means this
317            // is not a skill block.
318            if trailing.is_empty() {
319                None
320            } else {
321                return None;
322            }
323        }
324    };
325    Some(ParsedSkillBlock {
326        name: name.to_owned(),
327        location: location.to_owned(),
328        content: content.to_owned(),
329        user_message,
330    })
331}
332
333// ---------------------------------------------------------------------------
334// Internals
335// ---------------------------------------------------------------------------
336
337/// Convert a list of base tools into an insertion-ordered vector.
338///
339/// Duplicate names keep the first entry (matches the TypeScript
340/// `_baseToolDefinitions` Map construction, which also first-wins on dupes).
341fn build_base_definitions(tools: Vec<Arc<dyn AgentTool>>) -> Vec<Arc<dyn AgentTool>> {
342    let mut seen: HashSet<String> = HashSet::with_capacity(tools.len());
343    let mut out: Vec<Arc<dyn AgentTool>> = Vec::with_capacity(tools.len());
344    for tool in tools {
345        if seen.insert(tool.name().to_owned()) {
346            out.push(tool);
347        }
348    }
349    out
350}
351
352/// Remove duplicates while preserving the first-seen order.
353fn dedup_preserve_order(names: Vec<String>) -> Vec<String> {
354    let mut seen: HashSet<String> = HashSet::with_capacity(names.len());
355    let mut out = Vec::with_capacity(names.len());
356    for name in names {
357        if seen.contains(&name) {
358            continue;
359        }
360        seen.insert(name.clone());
361        out.push(name);
362    }
363    out
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use serde_json::Value;
370
371    struct StubTool {
372        name: String,
373        description: String,
374        parameters: Value,
375    }
376
377    impl StubTool {
378        fn new_arc(name: &str) -> Arc<Self> {
379            Arc::new(Self {
380                name: name.to_owned(),
381                description: format!("stub {name}"),
382                parameters: serde_json::json!({ "type": "object" }),
383            })
384        }
385    }
386
387    impl AgentTool for StubTool {
388        fn name(&self) -> &str {
389            &self.name
390        }
391        fn label(&self) -> &str {
392            &self.name
393        }
394        fn description(&self) -> &str {
395            &self.description
396        }
397        fn parameters(&self) -> &Value {
398            &self.parameters
399        }
400        fn validate_arguments(
401            &self,
402            args: &serde_json::Map<String, Value>,
403        ) -> Result<serde_json::Map<String, Value>, pi_agent::ToolError> {
404            Ok(args.clone())
405        }
406        fn execute(
407            &self,
408            _tool_call_id: &str,
409            _args: serde_json::Map<String, Value>,
410            _cancel: tokio_util::sync::CancellationToken,
411            _updates: pi_agent::ToolUpdates,
412        ) -> futures::future::BoxFuture<
413            'static,
414            Result<pi_agent::AgentToolResult, pi_agent::ToolError>,
415        > {
416            Box::pin(async { Ok(pi_agent::AgentToolResult::default()) })
417        }
418    }
419
420    #[test]
421    fn build_base_definitions_first_wins_and_preserves_order() {
422        let a = StubTool::new_arc("read");
423        let b = StubTool::new_arc("bash");
424        let dup = StubTool::new_arc("read");
425        let map = build_base_definitions(vec![a, b, dup]);
426        assert_eq!(map.len(), 2);
427        assert_eq!(map[0].name(), "read");
428        assert_eq!(map[1].name(), "bash");
429    }
430
431    #[test]
432    fn dedup_preserve_order_keeps_first() {
433        let names = vec![
434            "read".to_owned(),
435            "bash".to_owned(),
436            "read".to_owned(),
437            "edit".to_owned(),
438        ];
439        assert_eq!(dedup_preserve_order(names), vec!["read", "bash", "edit"]);
440    }
441
442    #[test]
443    fn tool_info_carries_name_description_parameters() {
444        let tool = StubTool::new_arc("grep");
445        let info = ToolInfo {
446            name: tool.name().to_owned(),
447            description: tool.description().to_owned(),
448            parameters: tool.parameters().clone(),
449        };
450        assert_eq!(info.name, "grep");
451        assert_eq!(info.description, "stub grep");
452        assert_eq!(info.parameters, serde_json::json!({ "type": "object" }));
453    }
454
455    #[test]
456    fn parses_simple_skill_block_without_user_message() -> Result<(), &'static str> {
457        let text = "<skill name=\"commit\" location=\"/sk/commit.md\">\nbody\n</skill>";
458        let parsed = parse_skill_block(text).ok_or("skill block should parse")?;
459        assert_eq!(parsed.name, "commit");
460        assert_eq!(parsed.location, "/sk/commit.md");
461        assert_eq!(parsed.content, "body");
462        assert!(parsed.user_message.is_none());
463        Ok(())
464    }
465
466    #[test]
467    fn parses_skill_block_with_user_message() -> Result<(), &'static str> {
468        let text =
469            "<skill name=\"commit\" location=\"/sk/commit.md\">\nbody\n</skill>\n\nfix the bug";
470        let parsed = parse_skill_block(text).ok_or("skill block should parse")?;
471        assert_eq!(parsed.user_message.as_deref(), Some("fix the bug"));
472        Ok(())
473    }
474
475    #[test]
476    fn returns_none_for_non_skill_text() {
477        assert!(parse_skill_block("hello world").is_none());
478        assert!(parse_skill_block("<other>").is_none());
479    }
480
481    #[test]
482    fn returns_none_for_trailing_garbage() {
483        let text = "<skill name=\"a\" location=\"b\">\nc\n</skill>extra";
484        assert!(parse_skill_block(text).is_none());
485    }
486
487    #[test]
488    fn parses_multi_line_body() -> Result<(), &'static str> {
489        let text = "<skill name=\"a\" location=\"b\">\nline1\nline2\nline3\n</skill>";
490        let parsed = parse_skill_block(text).ok_or("skill block should parse")?;
491        assert_eq!(parsed.content, "line1\nline2\nline3");
492        Ok(())
493    }
494
495    #[test]
496    fn empty_user_message_after_separator_is_none() -> Result<(), &'static str> {
497        let text = "<skill name=\"a\" location=\"b\">\nbody\n</skill>\n\n   ";
498        let parsed = parse_skill_block(text).ok_or("skill block should parse")?;
499        assert!(parsed.user_message.is_none());
500        Ok(())
501    }
502
503    #[test]
504    fn round_trips_with_expand_format() -> Result<(), &'static str> {
505        // Match the exact format emitted by expand_skill_invocation.
506        let text = "<skill name=\"commit\" location=\"/sk/commit.md\">\nReferences are relative to /sk.\n\nBody here\n</skill>";
507        let parsed = parse_skill_block(text).ok_or("skill block should parse")?;
508        assert_eq!(parsed.name, "commit");
509        assert_eq!(
510            parsed.content,
511            "References are relative to /sk.\n\nBody here"
512        );
513        Ok(())
514    }
515}