1use 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
23pub const BUILTIN_PATH_PREFIX: &str = "builtin:";
25
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase", tag = "kind", content = "id")]
29pub enum ToolSource {
30 Builtin,
32 Extension(String),
34 Sdk,
36}
37
38#[derive(Clone)]
40pub struct ToolEntry {
41 pub tool: Arc<dyn AgentTool>,
43 pub prompt_snippet: Option<String>,
45 pub prompt_guidelines: Vec<String>,
47 pub exposure: ToolExposure,
49 pub default_active: bool,
51 pub source: ToolSource,
53 pub namespace: Option<JsonValue>,
55 pub annotations: Option<JsonValue>,
57 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 #[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 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct ToolInfo {
114 pub name: String,
116 pub description: String,
118 pub parameters: JsonValue,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub prompt_guidelines: Option<Vec<String>>,
123 pub exposure: ToolExposure,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub namespace: Option<JsonValue>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub annotations: Option<JsonValue>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub reaches_tools: Option<JsonValue>,
134 pub source_info: SourceInfo,
136}
137
138#[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
172pub(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 #[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 #[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 #[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 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 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 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 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 #[must_use]
417 pub fn system_prompt_options(&self) -> BuildSystemPromptOptions {
418 lock(&self.inner.tools).base_options.clone()
419 }
420
421 #[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 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, §ions).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 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(®istered.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(®istered.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 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 pub(crate) fn hidden_declarations(&self) -> HashSet<String> {
609 lock(&self.inner.tools).hidden().clone()
610 }
611
612 pub(crate) fn clear_pending_tools(&self) {
614 lock(&self.inner.tools).pending.clear();
615 }
616}
617
618fn 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
625fn 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}