use std::collections::{BTreeMap, BTreeSet};
use crate::exposure::{canonical_tool_name, is_core_tool};
use bamboo_agent_core::ToolSchema;
use serde::{Deserialize, Serialize};
pub mod builtin_guides;
pub mod context;
use builtin_guides::builtin_tool_guide;
use context::{GuideBuildContext, GuideLanguage};
use crate::tools::ToolRegistry;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolExample {
pub scenario: String,
pub parameters: serde_json::Value,
pub explanation: String,
}
impl ToolExample {
pub fn new(
scenario: impl Into<String>,
parameters: serde_json::Value,
explanation: impl Into<String>,
) -> Self {
Self {
scenario: scenario.into(),
parameters,
explanation: explanation.into(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ToolCategory {
FileReading,
FileWriting,
CodeSearch,
CommandExecution,
GitOperations,
TaskManagement,
UserInteraction,
}
impl ToolCategory {
const ORDER: [ToolCategory; 7] = [
ToolCategory::FileReading,
ToolCategory::FileWriting,
ToolCategory::CodeSearch,
ToolCategory::CommandExecution,
ToolCategory::GitOperations,
ToolCategory::TaskManagement,
ToolCategory::UserInteraction,
];
pub fn ordered() -> &'static [ToolCategory] {
&Self::ORDER
}
fn title(self, language: GuideLanguage) -> &'static str {
match (self, language) {
(ToolCategory::FileReading, GuideLanguage::Chinese) => "File Reading Tools",
(ToolCategory::FileWriting, GuideLanguage::Chinese) => "File Writing Tools",
(ToolCategory::CodeSearch, GuideLanguage::Chinese) => "Code Search Tools",
(ToolCategory::CommandExecution, GuideLanguage::Chinese) => "Command Execution Tools",
(ToolCategory::GitOperations, GuideLanguage::Chinese) => "Git Tools",
(ToolCategory::TaskManagement, GuideLanguage::Chinese) => "Task Management Tools",
(ToolCategory::UserInteraction, GuideLanguage::Chinese) => "User Interaction Tools",
(ToolCategory::FileReading, GuideLanguage::English) => "File Reading Tools",
(ToolCategory::FileWriting, GuideLanguage::English) => "File Writing Tools",
(ToolCategory::CodeSearch, GuideLanguage::English) => "Code Search Tools",
(ToolCategory::CommandExecution, GuideLanguage::English) => "Command Tools",
(ToolCategory::GitOperations, GuideLanguage::English) => "Git Tools",
(ToolCategory::TaskManagement, GuideLanguage::English) => "Task Management Tools",
(ToolCategory::UserInteraction, GuideLanguage::English) => "User Interaction Tools",
}
}
fn description(self, language: GuideLanguage) -> &'static str {
match (self, language) {
(ToolCategory::FileReading, GuideLanguage::Chinese) => {
"Use these to understand existing files, directory structure, and metadata."
}
(ToolCategory::FileWriting, GuideLanguage::Chinese) => {
"Use these to create files or make content modifications."
}
(ToolCategory::CodeSearch, GuideLanguage::Chinese) => {
"Use these to locate definitions, references, and key text."
}
(ToolCategory::CommandExecution, GuideLanguage::Chinese) => {
"Use these to run commands, confirm or switch working directories."
}
(ToolCategory::GitOperations, GuideLanguage::Chinese) => {
"Use these to view repository status and code differences."
}
(ToolCategory::TaskManagement, GuideLanguage::Chinese) => {
"Use these to break down tasks and track execution progress."
}
(ToolCategory::UserInteraction, GuideLanguage::Chinese) => {
"Use this to confirm uncertain matters with the user."
}
(ToolCategory::FileReading, GuideLanguage::English) => {
"Use these to inspect existing files and structure."
}
(ToolCategory::FileWriting, GuideLanguage::English) => {
"Use these to create files and apply edits."
}
(ToolCategory::CodeSearch, GuideLanguage::English) => {
"Use these to find symbols, references, and patterns."
}
(ToolCategory::CommandExecution, GuideLanguage::English) => {
"Use these for shell commands and workspace context."
}
(ToolCategory::GitOperations, GuideLanguage::English) => {
"Use these to inspect repository status and diffs."
}
(ToolCategory::TaskManagement, GuideLanguage::English) => {
"Use these for planning and progress tracking."
}
(ToolCategory::UserInteraction, GuideLanguage::English) => {
"Use this when user clarification is required."
}
}
}
}
pub trait ToolGuide: Send + Sync {
fn tool_name(&self) -> &str;
fn when_to_use(&self) -> &str;
fn when_not_to_use(&self) -> &str;
fn examples(&self) -> Vec<ToolExample>;
fn related_tools(&self) -> Vec<&str>;
fn category(&self) -> ToolCategory;
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolGuideSpec {
pub tool_name: String,
pub when_to_use: String,
pub when_not_to_use: String,
pub examples: Vec<ToolExample>,
pub related_tools: Vec<String>,
pub category: ToolCategory,
}
impl ToolGuideSpec {
pub fn from_guide(guide: &dyn ToolGuide) -> Self {
Self {
tool_name: guide.tool_name().to_string(),
when_to_use: guide.when_to_use().to_string(),
when_not_to_use: guide.when_not_to_use().to_string(),
examples: guide.examples(),
related_tools: guide
.related_tools()
.into_iter()
.map(str::to_string)
.collect(),
category: guide.category(),
}
}
pub fn from_json_str(raw: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(raw)
}
pub fn from_yaml_str(raw: &str) -> Result<Self, serde_yaml::Error> {
serde_yaml::from_str(raw)
}
}
impl ToolGuide for ToolGuideSpec {
fn tool_name(&self) -> &str {
&self.tool_name
}
fn when_to_use(&self) -> &str {
&self.when_to_use
}
fn when_not_to_use(&self) -> &str {
&self.when_not_to_use
}
fn examples(&self) -> Vec<ToolExample> {
self.examples.clone()
}
fn related_tools(&self) -> Vec<&str> {
self.related_tools.iter().map(String::as_str).collect()
}
fn category(&self) -> ToolCategory {
self.category
}
}
pub struct EnhancedPromptBuilder;
impl EnhancedPromptBuilder {
pub fn build(
registry: Option<&ToolRegistry>,
available_schemas: &[ToolSchema],
context: &GuideBuildContext,
) -> String {
let mut tool_names: Vec<String> = available_schemas
.iter()
.map(|schema| schema.function.name.clone())
.collect();
tool_names.sort();
tool_names.dedup();
Self::build_for_tools(registry, &tool_names, available_schemas, context)
}
pub fn build_for_tools(
registry: Option<&ToolRegistry>,
tool_names: &[String],
fallback_schemas: &[ToolSchema],
context: &GuideBuildContext,
) -> String {
let guides = Self::collect_guides(registry, tool_names);
let activated = &context.activated_discoverable_tools;
let mut core_guides: Vec<ToolGuideSpec> = Vec::new();
let mut activated_discoverable: Vec<ToolGuideSpec> = Vec::new();
let mut inactive_discoverable: Vec<ToolGuideSpec> = Vec::new();
for guide in guides {
let canonical = canonical_tool_name(&guide.tool_name);
if is_core_tool(&canonical) {
core_guides.push(guide);
} else if activated.contains(&canonical) {
activated_discoverable.push(guide);
} else {
inactive_discoverable.push(guide);
}
}
let mut output = String::from("## Tool Usage Guidelines\n");
let mut rendered_any = false;
if !core_guides.is_empty() {
rendered_any = true;
let mut grouped: BTreeMap<ToolCategory, Vec<&ToolGuideSpec>> = BTreeMap::new();
for guide in &core_guides {
grouped.entry(guide.category).or_default().push(guide);
}
for guides in grouped.values_mut() {
guides.sort_by_key(|g| g.tool_name.clone());
}
for category in ToolCategory::ordered() {
let Some(category_guides) = grouped.get(category) else {
continue;
};
output.push_str(&format!("\n### {}\n", category.title(context.language)));
output.push_str(category.description(context.language));
output.push('\n');
for guide in category_guides {
Self::render_full_guide(&mut output, guide, context);
}
}
}
if !activated_discoverable.is_empty() {
rendered_any = true;
let mut grouped: BTreeMap<ToolCategory, Vec<&ToolGuideSpec>> = BTreeMap::new();
for guide in &activated_discoverable {
grouped.entry(guide.category).or_default().push(guide);
}
for guides in grouped.values_mut() {
guides.sort_by_key(|g| g.tool_name.clone());
}
output.push_str(&format!(
"\n### {}\n",
activated_discoverable_title(context.language)
));
output.push_str(activated_discoverable_description(context.language));
output.push('\n');
for category in ToolCategory::ordered() {
let Some(category_guides) = grouped.get(category) else {
continue;
};
output.push_str(&format!("\n#### {}\n", category.title(context.language)));
output.push_str(category.description(context.language));
output.push('\n');
for guide in category_guides {
Self::render_full_guide(&mut output, guide, context);
}
}
}
if !inactive_discoverable.is_empty() {
rendered_any = true;
output.push('\n');
output.push_str(&Self::render_discoverable_section(
&inactive_discoverable,
context,
));
}
let guided_names: BTreeSet<String> = core_guides
.iter()
.chain(activated_discoverable.iter())
.chain(inactive_discoverable.iter())
.map(|guide| guide.tool_name.clone())
.collect();
let unguided_schemas: Vec<ToolSchema> = fallback_schemas
.iter()
.filter(|schema| !guided_names.contains(schema.function.name.as_str()))
.cloned()
.collect();
if !unguided_schemas.is_empty() {
rendered_any = true;
output.push('\n');
output.push_str(&Self::render_schema_only_section(
&unguided_schemas,
context,
false,
));
}
if !rendered_any {
return Self::render_schema_only_section(fallback_schemas, context, true);
}
if context.include_best_practices {
output.push_str(&format!(
"\n### {}\n",
best_practices_title(context.language)
));
for (index, rule) in context.best_practices().iter().enumerate() {
output.push_str(&format!("{}. {}\n", index + 1, rule));
}
}
output
}
fn render_full_guide(output: &mut String, guide: &ToolGuideSpec, context: &GuideBuildContext) {
output.push_str(&format!("\n**{}**\n", guide.tool_name));
output.push_str(&format!(
"- {}: {}\n",
when_to_use_label(context.language),
guide.when_to_use
));
output.push_str(&format!(
"- {}: {}\n",
when_not_to_use_label(context.language),
guide.when_not_to_use
));
for example in guide.examples.iter().take(context.max_examples_per_tool) {
let params =
serde_json::to_string(&example.parameters).unwrap_or_else(|_| "{}".to_string());
output.push_str(&format!(
"- {}: {}\n -> {}\n",
example_label(context.language),
params,
example.explanation
));
}
if !guide.related_tools.is_empty() {
output.push_str(&format!(
"- {}: {}\n",
related_tools_label(context.language),
guide.related_tools.join(", ")
));
}
}
fn collect_guides(
registry: Option<&ToolRegistry>,
tool_names: &[String],
) -> Vec<ToolGuideSpec> {
let mut seen = BTreeSet::new();
let mut guides = Vec::new();
for raw_name in tool_names {
let name = raw_name.trim();
if name.is_empty() || !seen.insert(name.to_string()) {
continue;
}
let guide = registry
.and_then(|registry| registry.get_guide(name))
.or_else(|| builtin_tool_guide(name));
if let Some(guide) = guide {
guides.push(ToolGuideSpec::from_guide(guide.as_ref()));
}
}
guides.sort_by_key(|g| g.tool_name.clone());
guides
}
fn render_discoverable_section(
guides: &[ToolGuideSpec],
context: &GuideBuildContext,
) -> String {
if guides.is_empty() {
return String::new();
}
let mut sorted = guides.to_vec();
sorted.sort_by_key(|g| g.tool_name.clone());
let mut output = String::new();
output.push_str(&format!(
"### {}\n",
discoverable_tools_title(context.language)
));
output.push_str(discoverable_tools_description(context.language));
output.push('\n');
for guide in sorted {
output.push_str(&format!("- `{}`: {}\n", guide.tool_name, guide.when_to_use));
}
output
}
fn render_schema_only_section(
schemas: &[ToolSchema],
context: &GuideBuildContext,
include_header: bool,
) -> String {
if schemas.is_empty() {
return String::new();
}
let mut output = String::new();
if include_header {
output.push_str("## Tool Usage Guidelines\n");
}
output.push_str(&format!("\n### {}\n", schema_only_title(context.language)));
output.push_str(schema_only_description(context.language));
output.push('\n');
let mut sorted = schemas.to_vec();
sorted.sort_by_key(|s| s.function.name.clone());
for schema in sorted {
output.push_str(&format!(
"- `{}`: {}\n",
schema.function.name, schema.function.description
));
}
output
}
}
fn when_to_use_label(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "When to use",
GuideLanguage::English => "When to use",
}
}
fn when_not_to_use_label(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "When NOT to use",
GuideLanguage::English => "When NOT to use",
}
}
fn example_label(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "Example",
GuideLanguage::English => "Example",
}
}
fn related_tools_label(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "Related tools",
GuideLanguage::English => "Related tools",
}
}
fn best_practices_title(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "Best Practices",
GuideLanguage::English => "Best Practices",
}
}
fn discoverable_tools_title(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "Discoverable Tools",
GuideLanguage::English => "Discoverable Tools",
}
}
fn discoverable_tools_description(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => {
"These lower-frequency tools are available but listed here only in brief to save context. Their full parameter details and examples become visible once they are activated for this session (activation is handled by the app or user, not via a tool call)."
}
GuideLanguage::English => {
"These lower-frequency tools are available but listed here only in brief to save context. Their full parameter details and examples become visible once they are activated for this session (activation is handled by the app or user, not via a tool call)."
}
}
}
fn activated_discoverable_title(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "Activated Discoverable Tools",
GuideLanguage::English => "Activated Discoverable Tools",
}
}
fn activated_discoverable_description(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => {
"These discoverable tools are currently activated and available with full detail."
}
GuideLanguage::English => {
"These discoverable tools are currently activated and available with full detail."
}
}
}
fn schema_only_title(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "Additional Tools (Schema Only)",
GuideLanguage::English => "Additional Tools (Schema Only)",
}
}
fn schema_only_description(language: GuideLanguage) -> &'static str {
match language {
GuideLanguage::Chinese => "No detailed guide is available for these tools; rely on schema.",
GuideLanguage::English => "No detailed guide is available for these tools; rely on schema.",
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use serde_json::json;
use crate::{
tools::{ReadTool, ToolRegistry},
BuiltinToolExecutor,
};
use bamboo_agent_core::{FunctionSchema, ToolExecutor, ToolSchema};
use super::{
context::GuideBuildContext, context::GuideLanguage, EnhancedPromptBuilder, ToolCategory,
ToolGuideSpec,
};
fn render_legacy_full_prompt(schemas: &[ToolSchema], context: &GuideBuildContext) -> String {
let tool_names: Vec<String> = schemas
.iter()
.map(|schema| schema.function.name.clone())
.collect();
let guides = EnhancedPromptBuilder::collect_guides(None, &tool_names);
if guides.is_empty() {
return EnhancedPromptBuilder::render_schema_only_section(schemas, context, true);
}
let mut output = String::from("## Tool Usage Guidelines\n");
let mut grouped: BTreeMap<ToolCategory, Vec<&ToolGuideSpec>> = BTreeMap::new();
for guide in &guides {
grouped.entry(guide.category).or_default().push(guide);
}
for guides in grouped.values_mut() {
guides.sort_by_key(|g| g.tool_name.clone());
}
for category in ToolCategory::ordered() {
let Some(category_guides) = grouped.get(category) else {
continue;
};
output.push_str(&format!("\n### {}\n", category.title(context.language)));
output.push_str(category.description(context.language));
output.push('\n');
for guide in category_guides {
output.push_str(&format!("\n**{}**\n", guide.tool_name));
output.push_str(&format!(
"- {}: {}\n",
super::when_to_use_label(context.language),
guide.when_to_use
));
output.push_str(&format!(
"- {}: {}\n",
super::when_not_to_use_label(context.language),
guide.when_not_to_use
));
for example in guide.examples.iter().take(context.max_examples_per_tool) {
let params = serde_json::to_string(&example.parameters)
.unwrap_or_else(|_| "{}".to_string());
output.push_str(&format!(
"- {}: {}\n -> {}\n",
super::example_label(context.language),
params,
example.explanation
));
}
if !guide.related_tools.is_empty() {
output.push_str(&format!(
"- {}: {}\n",
super::related_tools_label(context.language),
guide.related_tools.join(", ")
));
}
}
}
let guided_names: BTreeSet<&str> = guides
.iter()
.map(|guide| guide.tool_name.as_str())
.collect();
let unguided_schemas: Vec<ToolSchema> = schemas
.iter()
.filter(|schema| !guided_names.contains(schema.function.name.as_str()))
.cloned()
.collect();
if !unguided_schemas.is_empty() {
output.push('\n');
output.push_str(&EnhancedPromptBuilder::render_schema_only_section(
&unguided_schemas,
context,
false,
));
}
if context.include_best_practices {
output.push_str(&format!(
"\n### {}\n",
super::best_practices_title(context.language)
));
for (index, rule) in context.best_practices().iter().enumerate() {
output.push_str(&format!("{}. {}\n", index + 1, rule));
}
}
output
}
#[test]
fn build_renders_builtin_guides() {
let registry = ToolRegistry::new();
registry.register(ReadTool::new()).unwrap();
let schemas = registry.list_tools();
let prompt =
EnhancedPromptBuilder::build(Some(®istry), &schemas, &GuideBuildContext::default());
assert!(prompt.contains("## Tool Usage Guidelines"));
assert!(prompt.contains("**Read**"));
}
#[test]
fn build_falls_back_to_schema_without_guides() {
let schema = ToolSchema {
schema_type: "function".to_string(),
function: FunctionSchema {
name: "dynamic_tool".to_string(),
description: "A runtime tool".to_string(),
parameters: json!({ "type": "object", "properties": {} }),
},
};
let context = GuideBuildContext {
language: GuideLanguage::English,
..GuideBuildContext::default()
};
let prompt = EnhancedPromptBuilder::build(None, &[schema], &context);
assert!(prompt.contains("Additional Tools (Schema Only)"));
assert!(prompt.contains("dynamic_tool"));
}
#[test]
fn build_summarizes_discoverable_tools() {
let registry = ToolRegistry::new();
registry.register(ReadTool::new()).unwrap();
registry.register(crate::tools::SleepTool::new()).unwrap();
let schemas = registry.list_tools();
let prompt =
EnhancedPromptBuilder::build(Some(®istry), &schemas, &GuideBuildContext::default());
assert!(prompt.contains("### Discoverable Tools"));
assert!(prompt.contains("`Sleep`"));
assert!(!prompt.contains("**Sleep**"));
}
#[test]
fn build_reduces_prompt_length_vs_legacy_full_guides_for_builtin_surface() {
let executor = BuiltinToolExecutor::new();
let schemas = executor.list_tools();
let context = GuideBuildContext::default();
let legacy = render_legacy_full_prompt(&schemas, &context);
let current = EnhancedPromptBuilder::build(None, &schemas, &context);
assert!(current.len() < legacy.len());
let saved = legacy.len() - current.len();
let saved_ratio = saved as f64 / legacy.len() as f64;
eprintln!(
"guide_length_metrics: legacy={}, current={}, saved={}, saved_ratio={:.3}",
legacy.len(),
current.len(),
saved,
saved_ratio,
);
assert!(
saved > 0,
"expected prompt savings for summarized discoverable tools"
);
}
#[test]
fn build_shows_activated_discoverable_tools_with_full_detail() {
let registry = ToolRegistry::new();
registry.register(crate::tools::SleepTool::new()).unwrap();
let schemas = registry.list_tools();
let mut context = GuideBuildContext::default();
context
.activated_discoverable_tools
.insert("Sleep".to_string());
let prompt = EnhancedPromptBuilder::build(Some(®istry), &schemas, &context);
assert!(
prompt.contains("### Activated Discoverable Tools"),
"activated discoverable section should appear"
);
assert!(
prompt.contains("**Sleep**"),
"activated Sleep should show full guide with bold name"
);
assert!(
prompt.contains("When to use"),
"activated Sleep should include when_to_use"
);
assert!(
prompt.contains("When NOT to use"),
"activated Sleep should include when_not_to_use"
);
assert!(
!prompt.contains("### Discoverable Tools"),
"inactive discoverable section should not appear when all discoverable tools are activated"
);
}
#[test]
fn build_shows_inactive_discoverable_tools_with_short_summary() {
let registry = ToolRegistry::new();
registry.register(crate::tools::SleepTool::new()).unwrap();
let schemas = registry.list_tools();
let context = GuideBuildContext::default();
let prompt = EnhancedPromptBuilder::build(Some(®istry), &schemas, &context);
assert!(
prompt.contains("### Discoverable Tools"),
"inactive discoverable section should appear"
);
assert!(
prompt.contains("`Sleep`"),
"inactive Sleep should show as short summary"
);
assert!(
!prompt.contains("**Sleep**"),
"inactive Sleep should NOT show full guide with bold name"
);
assert!(
!prompt.contains("Activated Discoverable Tools"),
"activated section should not appear when no discoverable tools are activated"
);
}
#[test]
fn build_separates_core_and_discoverable_tools_correctly() {
let registry = ToolRegistry::new();
registry.register(ReadTool::new()).unwrap();
registry.register(crate::tools::SleepTool::new()).unwrap();
let schemas = registry.list_tools();
let mut context = GuideBuildContext::default();
context
.activated_discoverable_tools
.insert("Sleep".to_string());
let prompt = EnhancedPromptBuilder::build(Some(®istry), &schemas, &context);
assert!(
prompt.contains("### File Reading Tools"),
"core tools should appear in category section"
);
assert!(
prompt.contains("**Read**"),
"core Read should show full guide"
);
assert!(
prompt.contains("### Activated Discoverable Tools"),
"activated discoverable section should appear"
);
assert!(
prompt.contains("**Sleep**"),
"activated Sleep should show full guide"
);
}
}