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 开发、rpi-extcore 复用边界与发布检查清单",
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_with_fallback(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
185/// Split a free-text query into lower-cased terms. A long Chinese query or a
186/// compound phrase used to miss every line because `search_page` did a single
187/// whole-string substring test (fork issue #14 §3: 32 of 51 `docs` calls
188/// returned `No matches`). Both whitespace and common punctuation are term
189/// separators; CJK characters are kept as runs so `资源发现` stays one term.
190fn query_terms(query: &str) -> Vec<String> {
191    query
192        .split(|c: char| c.is_whitespace() || matches!(c, '/' | ',' | ';' | ':' | '(' | ')' | '|'))
193        .filter_map(|part| {
194            let term = part
195                .trim_matches(|c: char| !c.is_alphanumeric())
196                .to_lowercase();
197            (!term.is_empty()).then_some(term)
198        })
199        .collect()
200}
201
202/// A line matches when **every** term appears in it. AND is deliberate: a
203/// multi-word query is a narrowing request, and OR would flood the result with
204/// lines that contain only `rpi` or only `config`.
205fn line_matches(line: &str, terms: &[String]) -> bool {
206    let lower = line.to_lowercase();
207    terms.iter().all(|term| lower.contains(term))
208}
209
210fn search_page(page: &DocPage, query: &str) -> String {
211    let terms = query_terms(query);
212    let lines: Vec<&str> = page.content.lines().collect();
213    let mut selected = Vec::new();
214    if !terms.is_empty() {
215        for (index, line) in lines.iter().enumerate() {
216            if !line_matches(line, &terms) {
217                continue;
218            }
219            let start = index.saturating_sub(2);
220            let end = (index + 3).min(lines.len());
221            for line_no in start..end {
222                if !selected.contains(&line_no) {
223                    selected.push(line_no);
224                }
225            }
226            if selected.len() >= MAX_SEARCH_RESULTS * 5 {
227                break;
228            }
229        }
230    }
231    if selected.is_empty() {
232        return format!(
233            "No matches for `{query}` in `{}`. Try docs with topic=list or a broader query.",
234            page.topic
235        );
236    }
237    let mut out = format!("Matches for `{query}` in `{}`:\n\n", page.topic);
238    for line_no in selected {
239        out.push_str(&format!("{:>5}: {}\n", line_no + 1, lines[line_no]));
240        if out.len() >= MAX_RESULT_CHARS {
241            out.push_str("\n[Result truncated; run another focused docs query.]\n");
242            break;
243        }
244    }
245    out
246}
247
248/// When a query has no hit in the selected topic, look through the other
249/// topics and return the first page that does. The output says which topic was
250/// used so a caller never mistakes a fallback hit for a hit in `page`.
251fn search_with_fallback(page: &DocPage, query: &str) -> String {
252    let direct = search_page(page, query);
253    if !direct.starts_with("No matches for `") {
254        return direct;
255    }
256    let terms = query_terms(query);
257    if terms.is_empty() {
258        return direct;
259    }
260    for other in DOCS
261        .iter()
262        .filter(|candidate| candidate.topic != page.topic)
263    {
264        if other.content.lines().any(|line| line_matches(line, &terms)) {
265            let body = search_page(other, query);
266            return format!(
267                "No matches for `{query}` in `{}`. Found it in `{}` instead:\n\n{body}",
268                page.topic, other.topic
269            );
270        }
271    }
272    direct
273}
274
275fn truncate_result(content: &str) -> String {
276    if content.len() <= MAX_RESULT_CHARS {
277        return content.to_string();
278    }
279    let mut end = MAX_RESULT_CHARS;
280    while end > 0 && !content.is_char_boundary(end) {
281        end -= 1;
282    }
283    format!(
284        "{}\n\n[Document truncated; use docs with a query for a focused section.]",
285        &content[..end]
286    )
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    async fn execute(input: serde_json::Value) -> String {
294        let tool = DocsTool::new();
295        let result = tool
296            .execute("test", input, CancellationToken::new(), Arc::new(|_| {}))
297            .await
298            .unwrap();
299        match &result.content[0] {
300            rpi_agent::types::TextContentOrImage::Text(text) => text.text.clone(),
301            _ => panic!("docs returned an image"),
302        }
303    }
304
305    #[tokio::test]
306    async fn lists_topics() {
307        let output = execute(serde_json::json!({})).await;
308        assert!(output.contains("overview"));
309        assert!(output.contains("guide"));
310        assert!(output.contains("authoring"));
311        assert!(output.contains("- agent:"));
312        assert!(output.contains("debugging"));
313        assert!(output.contains("统一 C ABI"));
314        assert!(!output.contains("ABI v3/v2"));
315        assert!(!output.contains("- extensions:"));
316        assert!(!output.contains("- compatibility:"));
317    }
318
319    #[tokio::test]
320    async fn returns_rust_debugging_guide() {
321        let output = execute(serde_json::json!({"topic": "debugging", "query": "rpi dev"})).await;
322        assert!(output.contains("rpi dev"));
323        assert!(output.contains("cdylib"));
324    }
325
326    #[tokio::test]
327    async fn returns_runtime_diagnostics_guide() {
328        let output = execute(serde_json::json!({
329            "topic": "debugging",
330            "query": "RPI_FRAME_DIAGNOSTICS"
331        }))
332        .await;
333        assert!(output.contains("RPI_FRAME_DIAGNOSTICS"));
334        assert!(output.contains("frame writer"));
335    }
336
337    #[tokio::test]
338    async fn returns_runtime_diagnostics_from_overview() {
339        let output = execute(serde_json::json!({
340            "topic": "overview",
341            "query": "RPI_AGENT_DIAGNOSTICS"
342        }))
343        .await;
344        assert!(output.contains("RPI_AGENT_DIAGNOSTICS"));
345        assert!(output.contains("latencies"));
346    }
347
348    #[tokio::test]
349    async fn returns_complete_user_guide() {
350        let output = execute(serde_json::json!({"topic": "guide", "query": "cdylib"})).await;
351        assert!(output.contains("cdylib"));
352        assert!(output.contains("Rust"));
353    }
354
355    #[tokio::test]
356    async fn returns_extension_authoring_practices() {
357        let output = execute(serde_json::json!({"topic": "authoring", "query": "rpi dev"})).await;
358        assert!(output.contains("rpi dev"));
359
360        // The cdylib build contract is a separate section from the `rpi`
361        // dev workflow text, so query for it directly rather than relying
362        // on both terms co-occurring in one excerpt window.
363        let cdylib = execute(serde_json::json!({"topic": "authoring", "query": "cdylib"})).await;
364        assert!(cdylib.contains("cdylib"));
365    }
366
367    #[tokio::test]
368    async fn returns_extcore_reuse_guidance() {
369        let output = execute(serde_json::json!({
370            "topic": "authoring",
371            "query": "rpi-extcore"
372        }))
373        .await;
374        assert!(output.contains("rpi-extcore"));
375        assert!(output.contains("rpi-plugin-sdk"));
376    }
377
378    #[tokio::test]
379    async fn returns_agent_project_guide() {
380        let output = execute(serde_json::json!({
381            "topic": "agent",
382            "query": "AgentBuilder"
383        }))
384        .await;
385        assert!(output.contains("AgentBuilder"));
386        assert!(output.contains("Matches for"));
387    }
388
389    #[tokio::test]
390    async fn agent_aliases_resolve() {
391        let output = execute(serde_json::json!({"topic": "create-agent"})).await;
392        assert!(output.contains("rpi Agent 项目结构创建指南"));
393    }
394
395    #[tokio::test]
396    async fn package_and_extension_aliases_resolve() {
397        for topic in ["package", "packages", "extension", "extensions", "PACKAGES"] {
398            let output = execute(serde_json::json!({"topic": topic, "query": "packages"})).await;
399            assert!(output.starts_with("# rpi docs: guide"));
400            assert!(output.contains("Matches for"));
401        }
402    }
403
404    #[tokio::test]
405    async fn rejects_unknown_topic() {
406        let error = DocsTool::new()
407            .execute(
408                "test",
409                serde_json::json!({"topic": "missing"}),
410                CancellationToken::new(),
411                Arc::new(|_| {}),
412            )
413            .await
414            .unwrap_err()
415            .to_string();
416        assert!(error.contains("Unknown rpi docs topic"));
417    }
418
419    /// The audit recorded 32 `No matches` responses out of 51 `docs` calls.
420    /// These are the reported shapes: long Chinese queries, compound keywords,
421    /// and multi-word phrases that never appear verbatim on one line.
422    #[test]
423    fn review_issue14_compound_queries_are_tokenized() {
424        let cases = [
425            "rpi dev 开发 循环",
426            "extension APIs .rpi configuration",
427            "models.json provider config",
428            "session resume 分页",
429            "RUSTFLAGS -D warnings cargo check",
430        ];
431        for query in cases {
432            let terms = query_terms(query);
433            assert!(
434                terms.len() >= 2,
435                "query must be split: {query} -> {terms:?}"
436            );
437        }
438        assert_eq!(
439            query_terms("AgentBuilder"),
440            vec!["agentbuilder".to_string()]
441        );
442        // Punctuation-only separators do not produce empty terms.
443        assert!(query_terms("/ , ; : |").is_empty());
444    }
445
446    /// AND, not OR: a line containing only one of two terms must not match.
447    #[test]
448    fn review_issue14_all_terms_must_appear_on_the_same_line() {
449        let terms = query_terms("models.json provider");
450        assert_eq!(terms, vec!["models.json", "provider"]);
451        assert!(line_matches("set models.json provider here", &terms));
452        assert!(!line_matches("models.json only", &terms));
453        assert!(!line_matches("provider only", &terms));
454        assert!(!line_matches("nothing relevant", &terms));
455        // Case-insensitive, and CJK runs are kept intact.
456        let cjk = query_terms("资源发现 skills");
457        assert_eq!(cjk, vec!["资源发现", "skills"]);
458        assert!(line_matches("使用 资源发现 和 Skills 目录", &cjk));
459    }
460
461    /// A query that misses the selected topic but exists elsewhere must be
462    /// answered from that other topic, with the substituted topic named.
463    #[tokio::test]
464    async fn review_issue14_no_match_falls_back_to_another_topic() {
465        // `RPI_FRAME_DIAGNOSTICS` lives in the debugging page; searching for it
466        // under `agent` used to return a bare `No matches`.
467        let output = execute(serde_json::json!({
468            "topic": "agent",
469            "query": "RPI_FRAME_DIAGNOSTICS"
470        }))
471        .await;
472        assert!(
473            output.contains("No matches for `RPI_FRAME_DIAGNOSTICS` in `agent`"),
474            "{output}"
475        );
476        assert!(output.contains("Found it in `"), "{output}");
477        assert!(!output.contains("Found it in `agent`"), "{output}");
478        assert!(output.contains("RPI_FRAME_DIAGNOSTICS"), "{output}");
479    }
480
481    /// A genuinely absent keyword must still say "No matches" and must not
482    /// claim a fallback that never happened.
483    #[tokio::test]
484    async fn review_issue14_unknown_query_still_reports_no_match() {
485        let output = execute(serde_json::json!({
486            "topic": "overview",
487            "query": "zzz-nonexistent-keyword-zzz"
488        }))
489        .await;
490        assert!(output.contains("No matches for"), "{output}");
491        assert!(!output.contains("Found it in"), "{output}");
492    }
493
494    /// Single-term and exact-phrase queries keep their previous behaviour: the
495    /// existing `rpi dev` / `cdylib` lookups must not regress into AND-only
496    /// misses.
497    #[tokio::test]
498    async fn review_issue14_existing_single_term_queries_still_hit() {
499        let output = execute(serde_json::json!({"topic": "debugging", "query": "rpi dev"})).await;
500        assert!(output.contains("Matches for `rpi dev`"), "{output}");
501        assert!(output.contains("rpi dev"), "{output}");
502    }
503}