use crate::graph::introspection::DescribeSurface;
use crate::graph::recipes;
use crate::graph::schema::DirGraph;
use crate::graph::skills;
use super::describe::xml_escape;
pub(crate) fn write_agent_guidance(xml: &mut String, graph: &DirGraph, surface: DescribeSurface) {
write_skills(xml, graph, surface);
write_recipes(xml, graph, surface);
}
fn write_skills(xml: &mut String, graph: &DirGraph, surface: DescribeSurface) {
let Some(fetch) = fetch_call(surface, "name") else {
return;
};
let skills = skills::list(graph);
if skills.is_empty() {
return;
}
xml.push_str(&format!(
" <skills count=\"{}\" hint=\"Methodology this graph carries for working with itself. Read one with {}.\">\n",
skills.len(),
xml_escape(&fetch),
));
for skill in &skills {
xml.push_str(&format!(
" <skill name=\"{}\" description=\"{}\"/>\n",
xml_escape(&skill.name),
xml_escape(&summary(&skill.description)),
));
}
xml.push_str(" </skills>\n");
}
fn write_recipes(xml: &mut String, graph: &DirGraph, surface: DescribeSurface) {
let Some(fetch) = recipe_fetch_call(surface) else {
return;
};
let records = recipes::list(graph);
if records.is_empty() {
return;
}
let mut groups: Vec<(&str, &str, usize)> = Vec::new();
for record in &records {
match groups.last_mut() {
Some((name, _, count)) if *name == record.recipe => *count += 1,
_ => groups.push((&record.recipe, &record.recipe_description, 1)),
}
}
xml.push_str(&format!(
" <recipes count=\"{}\" hint=\"Named read-only queries this graph carries for itself. Read one with {}; an MCP server serves them as run_recipe_query.\">\n",
groups.len(),
xml_escape(&fetch),
));
for (name, description, queries) in groups {
xml.push_str(&format!(
" <recipe name=\"{}\" queries=\"{queries}\" description=\"{}\"/>\n",
xml_escape(name),
xml_escape(&summary(description)),
));
}
xml.push_str(" </recipes>\n");
}
fn recipe_fetch_call(surface: DescribeSurface) -> Option<String> {
match surface {
DescribeSurface::Python => Some("get_recipe('recipe', 'name')".to_string()),
DescribeSurface::Cli => Some(
"kglite query GRAPH \"MATCH (r:KgliteRecipe) RETURN r.recipe, r.name, r.cypher\""
.to_string(),
),
DescribeSurface::Mcp => None,
}
}
fn fetch_call(surface: DescribeSurface, name: &str) -> Option<String> {
match surface {
DescribeSurface::Python => Some(format!("get_skill('{name}')")),
DescribeSurface::Cli => Some(format!("kglite skill GRAPH {name}")),
DescribeSurface::Mcp => None,
}
}
fn summary(description: &str) -> String {
const MAX: usize = 160;
let text = description.replace(['\n', '\r'], " ");
let text = text.trim();
let sentence_end = text.char_indices().find_map(|(index, ch)| {
let after = index + ch.len_utf8();
(ch == '.' && text[after..].chars().next().is_none_or(char::is_whitespace)).then_some(after)
});
match sentence_end {
Some(end) if end <= MAX => text[..end].to_string(),
_ if text.len() <= MAX => text.to_string(),
_ => {
let mut cut = MAX;
while !text.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\u{2026}", text[..cut].trim_end())
}
}
}
#[cfg(test)]
#[path = "skills_section_tests.rs"]
mod skills_section_tests;