Skip to main content

oino_core/session/
tools.rs

1//! Tool registry, loadout and the base system prompt options (`_refreshToolRegistry`,
2//! `_applyToolLoadout`, `_rebuildSystemPrompt`, `_preparePromptAndToolLoadout`,
3//! `_restoreToolsFromTranscript`).
4
5use std::collections::HashSet;
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use indexmap::{IndexMap, IndexSet};
10use oino_agent::AgentTool;
11use oino_ai::{AgentMessage, JsonValue, SystemMessage, UserTextField};
12use oino_resources::BuildSystemPromptOptions;
13use oino_resources::source::SourceInfo;
14use oino_resources::system_prompt::{build_system_prompt_sections, diff_system_prompt_sections};
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17
18use super::{AgentSession, current_system_message, lock};
19use crate::events::OinoEvent;
20use crate::extensions::api::ToolExposure;
21use crate::extensions::tool::ExtensionTool;
22
23/// Path prefix of built-in tools.
24pub const BUILTIN_PATH_PREFIX: &str = "builtin:";
25
26/// Where a tool comes from.
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase", tag = "kind", content = "id")]
29pub enum ToolSource {
30    /// Built-in (`oino-tools`).
31    Builtin,
32    /// An extension.
33    Extension(String),
34    /// SDK custom tool.
35    Sdk,
36}
37
38/// A tool with the metadata the session needs.
39#[derive(Clone)]
40pub struct ToolEntry {
41    /// Executable tool.
42    pub tool: Arc<dyn AgentTool>,
43    /// Prompt snippet (tools without one are not listed in the prompt).
44    pub prompt_snippet: Option<String>,
45    /// Prompt guidelines.
46    pub prompt_guidelines: Vec<String>,
47    /// Exposure.
48    pub exposure: ToolExposure,
49    /// Activated on registration.
50    pub default_active: bool,
51    /// Origin.
52    pub source: ToolSource,
53    /// Namespace.
54    pub namespace: Option<JsonValue>,
55    /// Annotations.
56    pub annotations: Option<JsonValue>,
57    /// Reachable tools.
58    pub reaches_tools: Option<JsonValue>,
59}
60
61impl std::fmt::Debug for ToolEntry {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("ToolEntry")
64            .field("name", &self.name())
65            .field("source", &self.source)
66            .finish_non_exhaustive()
67    }
68}
69
70impl ToolEntry {
71    /// A built-in tool entry.
72    #[must_use]
73    pub fn builtin(
74        tool: Arc<dyn AgentTool>,
75        prompt_snippet: Option<String>,
76        prompt_guidelines: Vec<String>,
77    ) -> Self {
78        Self {
79            tool,
80            prompt_snippet,
81            prompt_guidelines,
82            exposure: ToolExposure::Direct,
83            default_active: true,
84            source: ToolSource::Builtin,
85            namespace: None,
86            annotations: None,
87            reaches_tools: None,
88        }
89    }
90
91    /// Tool name.
92    #[must_use]
93    pub fn name(&self) -> &str {
94        &self.tool.meta().name
95    }
96
97    fn source_info(&self) -> SourceInfo {
98        let (path, source) = match &self.source {
99            ToolSource::Builtin => (
100                format!("{BUILTIN_PATH_PREFIX}{}", self.name()),
101                "builtin".to_owned(),
102            ),
103            ToolSource::Extension(id) => (id.clone(), format!("extension:{id}")),
104            ToolSource::Sdk => (format!("<sdk:{}>", self.name()), "sdk".to_owned()),
105        };
106        SourceInfo::synthetic(&PathBuf::from(path), &source, None, None)
107    }
108}
109
110/// `ToolInfo` (+ Oino `source`).
111#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct ToolInfo {
114    /// Name.
115    pub name: String,
116    /// Description.
117    pub description: String,
118    /// Schema.
119    pub parameters: JsonValue,
120    /// Prompt guidelines.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub prompt_guidelines: Option<Vec<String>>,
123    /// Exposure.
124    pub exposure: ToolExposure,
125    /// Namespace.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub namespace: Option<JsonValue>,
128    /// Annotations.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub annotations: Option<JsonValue>,
131    /// Reachable tools.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub reaches_tools: Option<JsonValue>,
134    /// Provenance.
135    pub source_info: SourceInfo,
136}
137
138/// Exact names and `*` patterns.
139#[derive(Clone, Debug, Default)]
140pub(crate) struct ToolMatcher {
141    names: HashSet<String>,
142    patterns: Vec<Regex>,
143}
144
145impl ToolMatcher {
146    pub(crate) fn new(entries: &[String]) -> Self {
147        let names = entries
148            .iter()
149            .filter(|e| !e.contains('*'))
150            .cloned()
151            .collect();
152        let patterns = entries
153            .iter()
154            .filter(|e| e.contains('*'))
155            .filter_map(|pattern| {
156                let source = pattern
157                    .split('*')
158                    .map(regex::escape)
159                    .collect::<Vec<_>>()
160                    .join(".*");
161                Regex::new(&format!("^{source}$")).ok()
162            })
163            .collect();
164        Self { names, patterns }
165    }
166
167    pub(crate) fn matches(&self, name: &str) -> bool {
168        self.names.contains(name) || self.patterns.iter().any(|p| p.is_match(name))
169    }
170}
171
172/// Tool bookkeeping of a session.
173pub(crate) struct ToolState {
174    builtin: Vec<ToolEntry>,
175    custom: Vec<ToolEntry>,
176    allowed: Option<ToolMatcher>,
177    excluded: ToolMatcher,
178    registry: IndexMap<String, ToolEntry>,
179    pending: IndexSet<String>,
180    hidden: HashSet<String>,
181    pub(crate) base_options: BuildSystemPromptOptions,
182}
183
184impl ToolState {
185    pub(crate) fn new(
186        builtin: Vec<ToolEntry>,
187        custom: Vec<ToolEntry>,
188        allowed: Option<&[String]>,
189        excluded: &[String],
190    ) -> Self {
191        Self {
192            builtin,
193            custom,
194            allowed: allowed.map(ToolMatcher::new),
195            excluded: ToolMatcher::new(excluded),
196            registry: IndexMap::new(),
197            pending: IndexSet::new(),
198            hidden: HashSet::new(),
199            base_options: BuildSystemPromptOptions::default(),
200        }
201    }
202
203    fn is_allowed(&self, name: &str) -> bool {
204        !self.excluded.matches(name) && self.allowed.as_ref().is_none_or(|a| a.matches(name))
205    }
206
207    fn exposure(&self, name: &str) -> ToolExposure {
208        self.registry
209            .get(name)
210            .map_or(ToolExposure::Direct, |e| e.exposure)
211    }
212
213    fn is_declarable(&self, name: &str) -> bool {
214        matches!(
215            self.exposure(name),
216            ToolExposure::Direct | ToolExposure::ModelOnly
217        )
218    }
219
220    fn activated_on_registration(&self, name: &str) -> bool {
221        self.is_declarable(name) && self.registry.get(name).is_some_and(|e| e.default_active)
222    }
223
224    pub(crate) fn hidden(&self) -> &HashSet<String> {
225        &self.hidden
226    }
227}
228
229impl AgentSession {
230    /// Names of the active tools (declared to the model).
231    #[must_use]
232    pub fn active_tool_names(&self) -> Vec<String> {
233        self.inner
234            .agent
235            .with_state(|state| state.tools.iter().map(|t| t.meta().name.clone()).collect())
236    }
237
238    /// Tools callable through nested calls: active `direct` tools plus `codemode`/`deferred`.
239    #[must_use]
240    pub fn callable_tool_names(&self) -> Vec<String> {
241        self.callable_tools(&self.active_tool_names().into_iter().collect())
242            .iter()
243            .map(|t| t.meta().name.clone())
244            .collect()
245    }
246
247    pub(crate) fn callable_tools(&self, active: &HashSet<String>) -> Vec<Arc<dyn AgentTool>> {
248        let tools = lock(&self.inner.tools);
249        tools
250            .registry
251            .values()
252            .filter(|entry| match entry.exposure {
253                ToolExposure::Codemode | ToolExposure::Deferred => true,
254                ToolExposure::Direct => active.contains(entry.name()),
255                ToolExposure::ModelOnly | ToolExposure::Hidden => false,
256            })
257            .map(|entry| Arc::clone(&entry.tool))
258            .collect()
259    }
260
261    /// Every registered tool.
262    #[must_use]
263    pub fn all_tools(&self) -> Vec<ToolInfo> {
264        let tools = lock(&self.inner.tools);
265        tools
266            .registry
267            .values()
268            .map(|entry| {
269                let meta = entry.tool.meta();
270                ToolInfo {
271                    name: meta.name.clone(),
272                    description: meta.description.clone(),
273                    parameters: meta.parameters.clone(),
274                    prompt_guidelines: (!entry.prompt_guidelines.is_empty())
275                        .then(|| entry.prompt_guidelines.clone()),
276                    exposure: entry.exposure,
277                    namespace: entry.namespace.clone(),
278                    annotations: entry.annotations.clone(),
279                    reaches_tools: entry.reaches_tools.clone(),
280                    source_info: entry.source_info(),
281                }
282            })
283            .collect()
284    }
285
286    /// Sets the active tools by name.
287    pub fn set_active_tools_by_name(&self, names: &[String]) {
288        let previous = self.active_tool_names();
289        self.set_active_tools(names);
290        let active: HashSet<String> = self.active_tool_names().into_iter().collect();
291        if previous.iter().any(|name| !active.contains(name)) {
292            lock(&self.inner.tools).pending.clear();
293        }
294    }
295
296    pub(crate) fn set_active_tools(&self, names: &[String]) {
297        let tools = self.apply_tool_loadout(names);
298        {
299            let mut state = lock(&self.inner.tools);
300            for tool in &tools {
301                state.pending.shift_remove(&tool.meta().name);
302            }
303        }
304        let names: Vec<String> = tools.iter().map(|t| t.meta().name.clone()).collect();
305        self.rebuild_system_prompt(&names);
306        self.inner
307            .hub
308            .publish(OinoEvent::ToolsChanged { active: names });
309    }
310
311    /// Sets the agent's tools to the registered, non-hidden ones.
312    pub(crate) fn apply_tool_loadout(&self, names: &[String]) -> Vec<Arc<dyn AgentTool>> {
313        let mut seen = HashSet::new();
314        let tools: Vec<Arc<dyn AgentTool>> = {
315            let state = lock(&self.inner.tools);
316            names
317                .iter()
318                .filter(|name| seen.insert((*name).clone()))
319                .filter_map(|name| {
320                    let entry = state.registry.get(name)?;
321                    (entry.exposure != ToolExposure::Hidden).then(|| Arc::clone(&entry.tool))
322                })
323                .collect()
324        };
325        lock(&self.inner.tools).hidden.clear();
326        self.inner.agent.set_tools(tools.clone());
327        tools
328    }
329
330    /// Recomputes the base prompt options.
331    pub(crate) fn rebuild_system_prompt(&self, names: &[String]) {
332        let (snippets, guidelines, valid, hidden) = {
333            let state = lock(&self.inner.tools);
334            let snippets: IndexMap<String, String> = state
335                .registry
336                .values()
337                .filter_map(|entry| {
338                    normalize_snippet(entry.prompt_snippet.as_deref())
339                        .map(|snippet| (entry.name().to_owned(), snippet))
340                })
341                .collect();
342            let guidelines: IndexMap<String, Vec<String>> = state
343                .registry
344                .values()
345                .filter_map(|entry| {
346                    let normalized = normalize_guidelines(&entry.prompt_guidelines);
347                    (!normalized.is_empty()).then(|| (entry.name().to_owned(), normalized))
348                })
349                .collect();
350            let valid: Vec<String> = names
351                .iter()
352                .filter(|name| state.registry.contains_key(*name))
353                .cloned()
354                .collect();
355            (
356                snippets,
357                guidelines,
358                valid,
359                state.hidden.iter().cloned().collect::<Vec<_>>(),
360            )
361        };
362        let resources = self
363            .inner
364            .resources
365            .read()
366            .unwrap_or_else(std::sync::PoisonError::into_inner);
367        let mut options = BuildSystemPromptOptions {
368            custom_prompt: resources.system_prompt().map(str::to_owned),
369            force_system_prompt: None,
370            selected_tools: valid,
371            hidden_tools: hidden,
372            tool_snippets: snippets,
373            tool_guidelines: guidelines,
374            prompt_guidelines: Vec::new(),
375            append_system_prompt: resources.append_system_prompt().join("\n\n"),
376            sections: IndexMap::new(),
377            cwd: self.inner.cwd.display().to_string(),
378            context_files: resources.context_files().to_vec(),
379            skills: resources.skills().to_vec(),
380        };
381        drop(resources);
382        self.apply_skill_opt_in(&mut options.skills);
383        if let Some(link) = &self.inner.agents.link {
384            match &link.prompt {
385                crate::agents::SystemPromptSpec::Default => {}
386                crate::agents::SystemPromptSpec::Replace(prompt) => {
387                    options.custom_prompt = Some(prompt.clone());
388                }
389                crate::agents::SystemPromptSpec::Append(extra) => {
390                    if options.append_system_prompt.is_empty() {
391                        options.append_system_prompt.clone_from(extra);
392                    } else {
393                        options.append_system_prompt =
394                            format!("{}\n\n{extra}", options.append_system_prompt);
395                    }
396                }
397            }
398        }
399        lock(&self.inner.tools).base_options = options;
400    }
401
402    /// Turns opt-in (`default-enabled: false`) skills on or off from the current
403    /// `extensionSettings.skills.states` (read at every rebuild and run, so a toggle in
404    /// `/skills` applies from the next prompt).
405    pub(crate) fn apply_skill_opt_in(&self, skills: &mut [oino_resources::Skill]) {
406        if skills.iter().all(|skill| skill.default_enabled) {
407            return;
408        }
409        let states = lock(&self.inner.settings)
410            .settings()
411            .extension_settings(oino_resources::skills::SKILL_STATES_NAMESPACE, None);
412        oino_resources::skills::apply_opt_in(skills, &states);
413    }
414
415    /// Base prompt options.
416    #[must_use]
417    pub fn system_prompt_options(&self) -> BuildSystemPromptOptions {
418        lock(&self.inner.tools).base_options.clone()
419    }
420
421    /// Current effective system prompt, including changes not yet sent.
422    #[must_use]
423    pub fn system_prompt(&self) -> String {
424        let options = lock(&self.inner.book)
425            .run_prompt_options
426            .clone()
427            .unwrap_or_else(|| self.system_prompt_options());
428        oino_resources::system_prompt::build_system_prompt(&options, &self.inner.docs_root)
429            .unwrap_or_default()
430    }
431
432    /// Applies `options` and returns the system patch.
433    pub(crate) fn prepare_prompt_and_tool_loadout(
434        &self,
435        options: &mut BuildSystemPromptOptions,
436        messages: &[AgentMessage],
437    ) -> Option<SystemMessage> {
438        options.selected_tools = self
439            .apply_tool_loadout(&options.selected_tools)
440            .iter()
441            .map(|t| t.meta().name.clone())
442            .collect();
443        options.hidden_tools = lock(&self.inner.tools).hidden().iter().cloned().collect();
444        let previous: IndexMap<String, String> = current_system_message(messages)
445            .and_then(|m| m.sections)
446            .map(|sections| {
447                sections
448                    .into_iter()
449                    .filter_map(|(name, text)| text.map(|text| (name, text)))
450                    .collect()
451            })
452            .unwrap_or_default();
453        let sections = match build_system_prompt_sections(options, &self.inner.docs_root) {
454            Ok(sections) => sections,
455            Err(error) => {
456                self.runner()
457                    .emit_error("<runtime>", "system_prompt", error.to_string());
458                return None;
459            }
460        };
461        diff_system_prompt_sections(&previous, &sections).map(|sections| SystemMessage {
462            content: UserTextField::Text(String::new()),
463            sections: Some(sections),
464            tools_added: None,
465            tools_removed: None,
466            timestamp: oino_agent::now_ms(),
467        })
468    }
469
470    /// Refreshes the tool registry.
471    pub(crate) fn refresh_tool_registry(
472        &self,
473        active: Option<Vec<String>>,
474        include_all_extension_tools: bool,
475    ) {
476        let runner = self.runner();
477        let cx = runner.call_context().clone();
478        let previous_active = self.active_tool_names();
479        let mut state = lock(&self.inner.tools);
480        let previous_activated: HashSet<String> = state
481            .registry
482            .keys()
483            .filter(|name| state.activated_on_registration(name))
484            .cloned()
485            .collect();
486        let mut registry: IndexMap<String, ToolEntry> = IndexMap::new();
487        for entry in state.builtin.iter().filter(|e| state.is_allowed(e.name())) {
488            registry.insert(entry.name().to_owned(), entry.clone());
489        }
490        let mut extension_names = Vec::new();
491        for registered in runner.tools() {
492            if !state.is_allowed(&registered.spec.name) {
493                continue;
494            }
495            let spec = registered.spec.clone();
496            let entry = ToolEntry {
497                tool: Arc::new(ExtensionTool::new(
498                    spec.clone(),
499                    Arc::clone(&registered.extension),
500                    &cx,
501                )),
502                prompt_snippet: spec.prompt_snippet.clone(),
503                prompt_guidelines: spec.prompt_guidelines.clone(),
504                exposure: spec.exposure,
505                default_active: spec.default_active.unwrap_or(true),
506                source: ToolSource::Extension(registered.extension.instance.id().to_owned()),
507                namespace: spec.namespace.clone(),
508                annotations: spec.annotations.clone(),
509                reaches_tools: spec.reaches_tools.clone(),
510            };
511            extension_names.push(spec.name.clone());
512            registry.insert(spec.name, entry);
513        }
514        for entry in state.custom.iter().filter(|e| state.is_allowed(e.name())) {
515            extension_names.push(entry.name().to_owned());
516            registry.insert(entry.name().to_owned(), entry.clone());
517        }
518        state.registry = registry;
519        let explicit = active.is_some();
520        let mut next: Vec<String> = active
521            .unwrap_or(previous_active)
522            .into_iter()
523            .filter(|name| state.is_allowed(name))
524            .collect();
525        if let Some(allowed) = state.allowed.clone() {
526            for name in state.registry.keys() {
527                if allowed.matches(name) && state.is_declarable(name) {
528                    next.push(name.clone());
529                }
530            }
531        } else if include_all_extension_tools {
532            for name in &extension_names {
533                if state.activated_on_registration(name) {
534                    next.push(name.clone());
535                }
536            }
537        } else if !explicit {
538            for name in state.registry.keys() {
539                if !previous_activated.contains(name) && state.activated_on_registration(name) {
540                    next.push(name.clone());
541                }
542            }
543        }
544        next.extend(state.pending.iter().cloned());
545        drop(state);
546        let mut seen = HashSet::new();
547        next.retain(|name| seen.insert(name.clone()));
548        self.set_active_tools(&next);
549    }
550
551    /// Restores the active tool set from the transcript.
552    pub(crate) fn restore_tools_from_transcript(&self, modifiers: &[String]) {
553        lock(&self.inner.tools).pending.clear();
554        let systems: Vec<SystemMessage> = lock(&self.inner.manager)
555            .branch(None)
556            .into_iter()
557            .filter_map(|entry| match entry.message() {
558                Some(AgentMessage::System(system)) => Some(system.clone()),
559                _ => None,
560            })
561            .collect();
562        if systems.is_empty() {
563            return;
564        }
565        let mentioned: HashSet<String> = systems
566            .iter()
567            .flat_map(|m| {
568                m.tools_added
569                    .iter()
570                    .flatten()
571                    .map(|t| t.name.clone())
572                    .chain(m.tools_removed.iter().flatten().map(|t| t.name.clone()))
573            })
574            .collect();
575        let recorded: Vec<String> = oino_ai::transcript::SystemState::fold(systems.iter())
576            .tools
577            .into_iter()
578            .map(|t| t.name)
579            .collect();
580        let names = {
581            let state = lock(&self.inner.tools);
582            let builtin: HashSet<&str> = state.builtin.iter().map(ToolEntry::name).collect();
583            let new_tools: Vec<String> = state
584                .registry
585                .keys()
586                .filter(|name| {
587                    !mentioned.contains(*name)
588                        && !builtin.contains(name.as_str())
589                        && state.activated_on_registration(name)
590                })
591                .cloned()
592                .collect();
593            oino_settings::tools::apply_tool_modifiers(&[recorded, new_tools].concat(), modifiers)
594        };
595        {
596            let mut state = lock(&self.inner.tools);
597            let pending: IndexSet<String> = names
598                .iter()
599                .filter(|n| state.is_allowed(n))
600                .cloned()
601                .collect();
602            state.pending = pending;
603        }
604        self.set_active_tools(&names);
605    }
606
607    /// Hidden declarations (`prepareLoadout`).
608    pub(crate) fn hidden_declarations(&self) -> HashSet<String> {
609        lock(&self.inner.tools).hidden().clone()
610    }
611
612    /// Clears pending (not yet registered) restored tools.
613    pub(crate) fn clear_pending_tools(&self) {
614        lock(&self.inner.tools).pending.clear();
615    }
616}
617
618/// Normalises a prompt snippet.
619fn normalize_snippet(text: Option<&str>) -> Option<String> {
620    let text = text?;
621    let one_line = text.split_whitespace().collect::<Vec<_>>().join(" ");
622    (!one_line.is_empty()).then_some(one_line)
623}
624
625/// Normalises prompt guidelines.
626fn normalize_guidelines(guidelines: &[String]) -> Vec<String> {
627    let mut unique = IndexSet::new();
628    for guideline in guidelines {
629        let normalized = guideline.trim();
630        if !normalized.is_empty() {
631            unique.insert(normalized.to_owned());
632        }
633    }
634    unique.into_iter().collect()
635}