Skip to main content

rpi_cli/
docs_tool.rs

1//! Built-in `docs` tool for looking up rpi usage documentation.
2//!
3//! Pi ships its coding-agent documentation with the CLI and lets the model
4//! reach it through the normal read/documentation flow. rpi keeps the same
5//! model-facing behavior with a small, read-only topic index. The selected
6//! Markdown pages are embedded at compile time so an installed binary does
7//! not depend on the source checkout being present.
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use rpi_agent::agent_tool::AgentTool;
13use rpi_agent::error::AgentError;
14use rpi_agent::types::{AgentToolResult, ToolExecutionMode, ToolResultPartial};
15use rpi_ai::types::Tool;
16use schemars::JsonSchema;
17use serde::Deserialize;
18use tokio_util::sync::CancellationToken;
19
20const MAX_SEARCH_RESULTS: usize = 8;
21const MAX_RESULT_CHARS: usize = 18_000;
22
23struct DocPage {
24    topic: &'static str,
25    description: &'static str,
26    content: &'static str,
27    aliases: &'static [&'static str],
28}
29
30static DOCS: &[DocPage] = &[
31    DocPage {
32        topic: "authoring",
33        description: "Rust cdylib 扩展开发最佳实践:统一 C ABI 入口、工具生命周期、事件处理器、资源发现、rpi dev 开发与发布检查清单",
34        content: include_str!("../embedded-docs/extension-authoring.md"),
35        aliases: &["package-authoring", "extension-authoring", "create-package", "create-extension"],
36    },
37    DocPage {
38        topic: "guide",
39        description: "完整使用手册:安装、模型、CLI、.rpi 资源、Rust 扩展、SDK、排错和发布",
40        content: include_str!("../embedded-docs/user-guide.md"),
41        aliases: &["manual", "user-guide", "cli", "quickstart", "package", "packages", "extension", "extensions"],
42    },
43    DocPage {
44        topic: "overview",
45        description: "rpi installation, built-in tools, configuration, extensions, and release basics",
46        content: include_str!("../embedded-docs/README.md"),
47        aliases: &["readme", "getting-started", "start", "usage"],
48    },
49    DocPage {
50        topic: "agent",
51        description: "用 rpi SDK 创建自己的 Agent 项目的默认结构与规范:目录布局、内嵌 Agent、cdylib 适配、工具单一登记点、prompts/skills、配置与开发循环",
52        content: include_str!("../embedded-docs/agent-project.md"),
53        aliases: &[
54            "agent-project",
55            "project-layout",
56            "create-agent",
57            "scaffold",
58            "project-structure",
59        ],
60    },
61    DocPage {
62        topic: "architecture",
63        description: "rpi crate layering, agent loop, provider, harness, sessions, and extension boundaries",
64        content: include_str!("../embedded-docs/architecture.md"),
65        aliases: &["design", "crates", "sdk"],
66    },
67    DocPage {
68        topic: "debugging",
69        description: "Rust cdylib 扩展编写(统一 C ABI、工具生命周期、事件处理器)与 agent/扩展调试(事件日志、rpi dev-local、常见失败定位)",
70        content: include_str!("../embedded-docs/rust-debugging.md"),
71        aliases: &["rust", "rust-debug", "extension-debug", "troubleshoot", "debug"],
72    },
73];
74
75#[derive(Debug, Clone, Deserialize, JsonSchema)]
76pub struct DocsInput {
77    /// Topic to open. Omit it, or use `list`, to list available topics.
78    #[serde(default)]
79    pub topic: Option<String>,
80    /// Optional case-insensitive text to find inside the selected topic.
81    #[serde(default)]
82    pub query: Option<String>,
83}
84
85pub struct DocsTool {
86    schema: Tool,
87}
88
89impl DocsTool {
90    fn new() -> Self {
91        let params = schemars::schema_for!(DocsInput);
92        Self {
93            schema: Tool {
94                name: "docs".to_string(),
95                description: "Look up rpi usage documentation. Omit topic (or use topic=list) to list topics; pass a topic such as guide, authoring, agent, debugging, architecture, or overview. Add query to find relevant sections. Use this before guessing rpi commands, creating an agent project or extension, extension APIs, or .rpi configuration.".to_string(),
96                parameters: rpi_ai::types::Schema::new(
97                    serde_json::to_value(params).unwrap_or_default(),
98                ),
99                constrained_sampling: None,
100            },
101        }
102    }
103}
104
105pub fn create_docs_tool() -> Arc<dyn AgentTool> {
106    Arc::new(DocsTool::new())
107}
108
109#[async_trait]
110impl AgentTool for DocsTool {
111    fn schema(&self) -> &Tool {
112        &self.schema
113    }
114
115    fn label(&self) -> &str {
116        "docs"
117    }
118
119    fn execution_mode(&self) -> ToolExecutionMode {
120        ToolExecutionMode::Parallel
121    }
122
123    async fn execute(
124        &self,
125        _tool_call_id: &str,
126        params: serde_json::Value,
127        signal: CancellationToken,
128        _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
129    ) -> Result<AgentToolResult, AgentError> {
130        let input: DocsInput = serde_json::from_value(params)
131            .map_err(|error| AgentError::Validation(format!("docs input invalid: {error}")))?;
132        if signal.is_cancelled() {
133            return Err(AgentError::Tool("docs lookup cancelled".into()));
134        }
135
136        let topic = input.topic.as_deref().unwrap_or("list").trim();
137        if topic.is_empty() || topic.eq_ignore_ascii_case("list") || topic == "*" {
138            return Ok(AgentToolResult::text(format_catalog()));
139        }
140
141        let page = DOCS
142            .iter()
143            .find(|page| {
144                page.topic.eq_ignore_ascii_case(topic)
145                    || page
146                        .aliases
147                        .iter()
148                        .any(|alias| alias.eq_ignore_ascii_case(topic))
149            })
150            .ok_or_else(|| {
151                AgentError::Tool(format!(
152                    "Unknown rpi docs topic `{topic}`. Available topics: {}",
153                    DOCS.iter()
154                        .map(|page| page.topic)
155                        .collect::<Vec<_>>()
156                        .join(", ")
157                ))
158            })?;
159
160        let query = input
161            .query
162            .as_deref()
163            .map(str::trim)
164            .filter(|value| !value.is_empty());
165        let body = match query {
166            Some(query) => search_page(page, query),
167            None => truncate_result(page.content),
168        };
169        Ok(AgentToolResult::text(format!(
170            "# rpi docs: {}\n\n{}",
171            page.topic, body
172        )))
173    }
174}
175
176fn format_catalog() -> String {
177    let mut out = String::from("Available rpi documentation topics:\n");
178    for page in DOCS {
179        out.push_str(&format!("- {}: {}\n", page.topic, page.description));
180    }
181    out.push_str("\nUse docs with {\"topic\": \"agent\"} or add {\"query\": \"AgentBuilder\"} for a focused lookup.");
182    out
183}
184
185fn search_page(page: &DocPage, query: &str) -> String {
186    let needle = query.to_lowercase();
187    let lines: Vec<&str> = page.content.lines().collect();
188    let mut selected = Vec::new();
189    for (index, line) in lines.iter().enumerate() {
190        if !line.to_lowercase().contains(&needle) {
191            continue;
192        }
193        let start = index.saturating_sub(2);
194        let end = (index + 3).min(lines.len());
195        for line_no in start..end {
196            if !selected.contains(&line_no) {
197                selected.push(line_no);
198            }
199        }
200        if selected.len() >= MAX_SEARCH_RESULTS * 5 {
201            break;
202        }
203    }
204    if selected.is_empty() {
205        return format!(
206            "No matches for `{query}` in `{}`. Try docs with topic=list or a broader query.",
207            page.topic
208        );
209    }
210    let mut out = format!("Matches for `{query}` in `{}`:\n\n", page.topic);
211    for line_no in selected {
212        out.push_str(&format!("{:>5}: {}\n", line_no + 1, lines[line_no]));
213        if out.len() >= MAX_RESULT_CHARS {
214            out.push_str("\n[Result truncated; run another focused docs query.]\n");
215            break;
216        }
217    }
218    out
219}
220
221fn truncate_result(content: &str) -> String {
222    if content.len() <= MAX_RESULT_CHARS {
223        return content.to_string();
224    }
225    let mut end = MAX_RESULT_CHARS;
226    while end > 0 && !content.is_char_boundary(end) {
227        end -= 1;
228    }
229    format!(
230        "{}\n\n[Document truncated; use docs with a query for a focused section.]",
231        &content[..end]
232    )
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    async fn execute(input: serde_json::Value) -> String {
240        let tool = DocsTool::new();
241        let result = tool
242            .execute("test", input, CancellationToken::new(), Arc::new(|_| {}))
243            .await
244            .unwrap();
245        match &result.content[0] {
246            rpi_agent::types::TextContentOrImage::Text(text) => text.text.clone(),
247            _ => panic!("docs returned an image"),
248        }
249    }
250
251    #[tokio::test]
252    async fn lists_topics() {
253        let output = execute(serde_json::json!({})).await;
254        assert!(output.contains("overview"));
255        assert!(output.contains("guide"));
256        assert!(output.contains("authoring"));
257        assert!(output.contains("- agent:"));
258        assert!(output.contains("debugging"));
259        assert!(output.contains("统一 C ABI"));
260        assert!(!output.contains("ABI v3/v2"));
261        assert!(!output.contains("- extensions:"));
262        assert!(!output.contains("- compatibility:"));
263    }
264
265    #[tokio::test]
266    async fn returns_rust_debugging_guide() {
267        let output = execute(serde_json::json!({"topic": "debugging", "query": "rpi dev"})).await;
268        assert!(output.contains("rpi dev"));
269        assert!(output.contains("cdylib"));
270    }
271
272    #[tokio::test]
273    async fn returns_complete_user_guide() {
274        let output = execute(serde_json::json!({"topic": "guide", "query": "cdylib"})).await;
275        assert!(output.contains("cdylib"));
276        assert!(output.contains("Rust"));
277    }
278
279    #[tokio::test]
280    async fn returns_extension_authoring_practices() {
281        let output = execute(serde_json::json!({"topic": "authoring", "query": "rpi dev"})).await;
282        assert!(output.contains("rpi dev"));
283
284        // The cdylib build contract is a separate section from the `rpi dev`
285        // workflow text, so query for it directly rather than relying on both
286        // terms co-occurring in one excerpt window.
287        let cdylib = execute(serde_json::json!({"topic": "authoring", "query": "cdylib"})).await;
288        assert!(cdylib.contains("cdylib"));
289    }
290
291    #[tokio::test]
292    async fn returns_agent_project_guide() {
293        let output = execute(serde_json::json!({
294            "topic": "agent",
295            "query": "AgentBuilder"
296        }))
297        .await;
298        assert!(output.contains("AgentBuilder"));
299        assert!(output.contains("Matches for"));
300    }
301
302    #[tokio::test]
303    async fn agent_aliases_resolve() {
304        let output = execute(serde_json::json!({"topic": "create-agent"})).await;
305        assert!(output.contains("rpi Agent 项目结构创建指南"));
306    }
307
308    #[tokio::test]
309    async fn package_and_extension_aliases_resolve() {
310        for topic in ["package", "packages", "extension", "extensions", "PACKAGES"] {
311            let output = execute(serde_json::json!({"topic": topic, "query": "packages"})).await;
312            assert!(output.starts_with("# rpi docs: guide"));
313            assert!(output.contains("Matches for"));
314        }
315    }
316
317    #[tokio::test]
318    async fn rejects_unknown_topic() {
319        let error = DocsTool::new()
320            .execute(
321                "test",
322                serde_json::json!({"topic": "missing"}),
323                CancellationToken::new(),
324                Arc::new(|_| {}),
325            )
326            .await
327            .unwrap_err()
328            .to_string();
329        assert!(error.contains("Unknown rpi docs topic"));
330    }
331}