use anyhow::Result;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use crate::tools::core::{RecoverableError, Tool, ToolContext};
pub struct GetGuide {
topics: BTreeMap<&'static str, &'static str>,
}
impl GetGuide {
pub fn new() -> Self {
let mut topics: BTreeMap<&'static str, &'static str> = BTreeMap::new();
for &topic in crate::prompts::GUIDE_TOPICS {
if let Some(body) = crate::prompts::topic_body(topic) {
topics.insert(topic, body);
}
}
Self { topics }
}
}
impl Default for GetGuide {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl Tool for GetGuide {
fn name(&self) -> &str {
"get_guide"
}
fn description(&self) -> &str {
"Deep guidance for a topic. Use when the system prompt points here. \
Topics: librarian | librarian-runtime | tracker-conventions | progressive-disclosure | \
error-handling | workspace-state | iron-laws-detail | \
symbol-navigation. No args = list \
topics + summaries. Full guide returned inline."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"topic": {
"type": "string",
"description": "Topic to fetch. Omit to list available topics.",
"enum": crate::prompts::GUIDE_TOPICS
}
},
"additionalProperties": false
})
}
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value> {
let topic = input.get("topic").and_then(|v| v.as_str());
match topic {
None => Ok(json!({
"topics": self.topics.keys().collect::<Vec<_>>(),
"summaries": {
"librarian": "artifact model, filter syntax, trackers, augmentations",
"librarian-runtime": "caps, scope hints, SQL filter semantics, gather sources, catalog DB location, classifier overrides, event-authorship",
"tracker-conventions": "frontmatter, archive flow, status vocabulary",
"progressive-disclosure": "MAX_INLINE_TOKENS, @ref buffer, overflow patterns",
"error-handling": "RecoverableError vs anyhow::bail, is_error routing",
"workspace-state": "activate_project semantics, home/foreign, per-session reset, subagent inheritance",
"iron-laws-detail": "per-law gate text, exceptions, edge cases for Iron Laws 1-6",
"symbol-navigation": "per-language symbols/references/call_graph nav tips"
}
})),
Some(t) => match self.topics.get(t) {
Some(body) => {
let first_fetch = ctx.guide_hints_emitted.lock().insert(t.to_string());
let note = if first_fetch {
format!(
"This guide is static and now in your context. Don't re-call \
get_guide(\"{t}\") this session unless your context was compacted."
)
} else {
format!(
"You already fetched get_guide(\"{t}\") earlier this session. This \
guide is static — if the earlier copy is still in your context, no \
need to re-read it. (Re-fetch is only needed after compaction.)"
)
};
Ok(json!({ "topic": t, "body": *body, "note": note }))
}
None => {
let available = self.topics.keys().cloned().collect::<Vec<_>>().join(", ");
Err(RecoverableError::with_hint(
format!("unknown topic '{t}'"),
format!("available topics: {available}"),
)
.into())
}
},
}
}
fn force_inline(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn ctx() -> ToolContext {
ToolContext {
agent: crate::agent::Agent::new(None).await.unwrap(),
lsp: crate::lsp::LspManager::new_arc(),
output_buffer: std::sync::Arc::new(crate::tools::output_buffer::OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
}
}
#[tokio::test]
async fn get_guide_lists_topics_with_no_arg() {
let g = GetGuide::new();
let result = g.call(json!({}), &ctx().await).await.unwrap();
let topics = result["topics"].as_array().unwrap();
let names: Vec<&str> = topics.iter().map(|v| v.as_str().unwrap()).collect();
assert!(names.contains(&"librarian"));
assert!(names.contains(&"librarian-runtime"));
assert!(names.contains(&"tracker-conventions"));
assert!(names.contains(&"progressive-disclosure"));
assert!(names.contains(&"error-handling"));
assert!(names.contains(&"workspace-state"));
assert!(names.contains(&"iron-laws-detail"));
assert!(names.contains(&"symbol-navigation"));
assert_eq!(names.len(), 8);
}
#[tokio::test]
async fn get_guide_returns_librarian_body() {
let g = GetGuide::new();
let result = g
.call(json!({"topic": "librarian"}), &ctx().await)
.await
.unwrap();
assert_eq!(result["topic"].as_str(), Some("librarian"));
let body = result["body"].as_str().unwrap();
assert!(!body.is_empty());
assert!(
body.contains("artifact"),
"should mention artifact in librarian guide"
);
}
#[tokio::test]
async fn get_guide_large_topic_returns_full_body_inline_not_buffered() {
let g = GetGuide::new();
let ctx = ctx().await;
let val = g.call(json!({"topic": "librarian"}), &ctx).await.unwrap();
let json_len = serde_json::to_string(&val).unwrap().len();
assert!(
json_len > 10_000,
"librarian guide must exceed the ~10 KB inline threshold for this \
test to be meaningful, got {json_len} bytes"
);
let content = g
.call_content(json!({"topic": "librarian"}), &ctx)
.await
.unwrap();
assert_eq!(content.len(), 1, "guide must be a single inline block");
let text = content[0].as_text().map(|t| t.text.as_str()).unwrap_or("");
assert!(
!text.contains("@tool_"),
"guide must NOT be diverted to a @tool_ buffer handle, got: {}",
&text[..text.len().min(200)]
);
assert!(
text.contains("artifact"),
"the full librarian guide body must be present inline"
);
}
#[tokio::test]
async fn get_guide_unknown_topic_is_recoverable() {
let g = GetGuide::new();
let err = g
.call(json!({"topic": "nonexistent"}), &ctx().await)
.await
.unwrap_err();
let rec = err
.downcast_ref::<RecoverableError>()
.expect("should be RecoverableError");
assert!(rec.message.contains("unknown topic"));
assert!(rec.hint().unwrap().contains("librarian"));
}
#[tokio::test]
async fn every_topic_has_non_empty_body() {
let g = GetGuide::new();
let list = g.call(json!({}), &ctx().await).await.unwrap();
let topics = list["topics"]
.as_array()
.expect("topics array in no-arg response");
assert!(
!topics.is_empty(),
"GetGuide must register at least one topic"
);
for topic in topics {
let name = topic.as_str().unwrap();
let result = g
.call(json!({"topic": name}), &ctx().await)
.await
.unwrap_or_else(|e| panic!("topic '{name}' failed: {e}"));
let body = result["body"]
.as_str()
.unwrap_or_else(|| panic!("topic '{name}' returned no body field"));
assert!(
body.len() > 100,
"topic '{name}' body suspiciously short ({} bytes) — likely empty or wrong include_str! target",
body.len()
);
}
}
#[tokio::test]
async fn schema_enum_matches_registered_topics() {
use std::collections::BTreeSet;
let g = GetGuide::new();
let schema = g.input_schema();
let enum_arr = schema["properties"]["topic"]["enum"]
.as_array()
.expect("schema must have properties.topic.enum");
let schema_topics: BTreeSet<String> = enum_arr
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
let list = g.call(json!({}), &ctx().await).await.unwrap();
let registered_topics: BTreeSet<String> = list["topics"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(
schema_topics, registered_topics,
"input_schema enum drifted from GetGuide::topics map — add the new topic to both or neither"
);
}
#[tokio::test]
async fn repeat_fetch_keeps_body_and_flags_static() {
let g = GetGuide::new();
let tc = ctx().await;
let first = g
.call(json!({"topic": "tracker-conventions"}), &tc)
.await
.unwrap();
assert!(!first["body"].as_str().unwrap().is_empty());
let first_note = first["note"].as_str().expect("first fetch has a note");
assert!(
first_note.contains("Don't re-call"),
"first fetch should discourage re-calling, got: {first_note}"
);
assert!(tc
.guide_hints_emitted
.lock()
.contains("tracker-conventions"));
let second = g
.call(json!({"topic": "tracker-conventions"}), &tc)
.await
.unwrap();
assert_eq!(
second["body"].as_str(),
first["body"].as_str(),
"repeat fetch must return the identical full body, not a stub"
);
let second_note = second["note"].as_str().expect("repeat fetch has a note");
assert!(
second_note.contains("already fetched"),
"repeat fetch note should flag the prior fetch, got: {second_note}"
);
}
}