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: "创建 Pi JS/TS package 与 Rust cdylib 扩展的模板、开发流程、安全边界、测试和发布最佳实践",
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 资源、Pi package、扩展、SDK、排错和发布",
40        content: include_str!("../embedded-docs/user-guide.md"),
41        aliases: &["manual", "user-guide", "cli", "quickstart"],
42    },
43    DocPage {
44        topic: "overview",
45        description: "rpi installation, built-in tools, configuration, packages, and release basics",
46        content: include_str!("../embedded-docs/README.md"),
47        aliases: &["readme", "getting-started", "start", "usage"],
48    },
49    DocPage {
50        topic: "extensions",
51        description: "Rust plugins, Pi JavaScript/TypeScript extensions, runtime capabilities, and UI compatibility",
52        content: include_str!("../embedded-docs/extension-backends.md"),
53        aliases: &["plugin", "plugins", "extension", "js", "typescript", "ts"],
54    },
55    DocPage {
56        topic: "architecture",
57        description: "rpi crate layering, agent loop, provider, harness, sessions, and extension boundaries",
58        content: include_str!("../embedded-docs/architecture.md"),
59        aliases: &["design", "crates", "sdk"],
60    },
61    DocPage {
62        topic: "compatibility",
63        description: "known Pi parity decisions, resource precedence, and remaining compatibility notes",
64        content: include_str!("../embedded-docs/m6-cli-open-questions.md"),
65        aliases: &["pi", "parity", "migration"],
66    },
67];
68
69#[derive(Debug, Clone, Deserialize, JsonSchema)]
70pub struct DocsInput {
71    /// Topic to open. Omit it, or use `list`, to list available topics.
72    #[serde(default)]
73    pub topic: Option<String>,
74    /// Optional case-insensitive text to find inside the selected topic.
75    #[serde(default)]
76    pub query: Option<String>,
77}
78
79pub struct DocsTool {
80    schema: Tool,
81}
82
83impl DocsTool {
84    fn new() -> Self {
85        let params = schemars::schema_for!(DocsInput);
86        Self {
87            schema: Tool {
88                name: "docs".to_string(),
89                description: "Look up rpi usage documentation. Omit topic (or use topic=list) to list topics; pass a topic such as guide, authoring, extensions, architecture, or compatibility. Add query to find relevant sections. Use this before guessing rpi commands, creating packages/extensions, Pi compatibility, extension APIs, or .rpi configuration.".to_string(),
90                parameters: rpi_ai::types::Schema::new(
91                    serde_json::to_value(params).unwrap_or_default(),
92                ),
93                constrained_sampling: None,
94            },
95        }
96    }
97}
98
99pub fn create_docs_tool() -> Arc<dyn AgentTool> {
100    Arc::new(DocsTool::new())
101}
102
103#[async_trait]
104impl AgentTool for DocsTool {
105    fn schema(&self) -> &Tool {
106        &self.schema
107    }
108
109    fn label(&self) -> &str {
110        "docs"
111    }
112
113    fn execution_mode(&self) -> ToolExecutionMode {
114        ToolExecutionMode::Parallel
115    }
116
117    async fn execute(
118        &self,
119        _tool_call_id: &str,
120        params: serde_json::Value,
121        signal: CancellationToken,
122        _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
123    ) -> Result<AgentToolResult, AgentError> {
124        let input: DocsInput = serde_json::from_value(params)
125            .map_err(|error| AgentError::Validation(format!("docs input invalid: {error}")))?;
126        if signal.is_cancelled() {
127            return Err(AgentError::Tool("docs lookup cancelled".into()));
128        }
129
130        let topic = input.topic.as_deref().unwrap_or("list").trim();
131        if topic.is_empty() || topic.eq_ignore_ascii_case("list") || topic == "*" {
132            return Ok(AgentToolResult::text(format_catalog()));
133        }
134
135        let page = DOCS
136            .iter()
137            .find(|page| {
138                page.topic.eq_ignore_ascii_case(topic)
139                    || page
140                        .aliases
141                        .iter()
142                        .any(|alias| alias.eq_ignore_ascii_case(topic))
143            })
144            .ok_or_else(|| {
145                AgentError::Tool(format!(
146                    "Unknown rpi docs topic `{topic}`. Available topics: {}",
147                    DOCS.iter()
148                        .map(|page| page.topic)
149                        .collect::<Vec<_>>()
150                        .join(", ")
151                ))
152            })?;
153
154        let query = input
155            .query
156            .as_deref()
157            .map(str::trim)
158            .filter(|value| !value.is_empty());
159        let body = match query {
160            Some(query) => search_page(page, query),
161            None => truncate_result(page.content),
162        };
163        Ok(AgentToolResult::text(format!(
164            "# rpi docs: {}\n\n{}",
165            page.topic, body
166        )))
167    }
168}
169
170fn format_catalog() -> String {
171    let mut out = String::from("Available rpi documentation topics:\n");
172    for page in DOCS {
173        out.push_str(&format!("- {}: {}\n", page.topic, page.description));
174    }
175    out.push_str("\nUse docs with {\"topic\": \"extensions\"} or add {\"query\": \"install-pi\"} for a focused lookup.");
176    out
177}
178
179fn search_page(page: &DocPage, query: &str) -> String {
180    let needle = query.to_lowercase();
181    let lines: Vec<&str> = page.content.lines().collect();
182    let mut selected = Vec::new();
183    for (index, line) in lines.iter().enumerate() {
184        if !line.to_lowercase().contains(&needle) {
185            continue;
186        }
187        let start = index.saturating_sub(2);
188        let end = (index + 3).min(lines.len());
189        for line_no in start..end {
190            if !selected.contains(&line_no) {
191                selected.push(line_no);
192            }
193        }
194        if selected.len() >= MAX_SEARCH_RESULTS * 5 {
195            break;
196        }
197    }
198    if selected.is_empty() {
199        return format!(
200            "No matches for `{query}` in `{}`. Try docs with topic=list or a broader query.",
201            page.topic
202        );
203    }
204    let mut out = format!("Matches for `{query}` in `{}`:\n\n", page.topic);
205    for line_no in selected {
206        out.push_str(&format!("{:>5}: {}\n", line_no + 1, lines[line_no]));
207        if out.len() >= MAX_RESULT_CHARS {
208            out.push_str("\n[Result truncated; run another focused docs query.]\n");
209            break;
210        }
211    }
212    out
213}
214
215fn truncate_result(content: &str) -> String {
216    if content.len() <= MAX_RESULT_CHARS {
217        return content.to_string();
218    }
219    let mut end = MAX_RESULT_CHARS;
220    while end > 0 && !content.is_char_boundary(end) {
221        end -= 1;
222    }
223    format!(
224        "{}\n\n[Document truncated; use docs with a query for a focused section.]",
225        &content[..end]
226    )
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    async fn execute(input: serde_json::Value) -> String {
234        let tool = DocsTool::new();
235        let result = tool
236            .execute("test", input, CancellationToken::new(), Arc::new(|_| {}))
237            .await
238            .unwrap();
239        match &result.content[0] {
240            rpi_agent::types::TextContentOrImage::Text(text) => text.text.clone(),
241            _ => panic!("docs returned an image"),
242        }
243    }
244
245    #[tokio::test]
246    async fn lists_topics() {
247        let output = execute(serde_json::json!({})).await;
248        assert!(output.contains("overview"));
249        assert!(output.contains("guide"));
250        assert!(output.contains("authoring"));
251        assert!(output.contains("extensions"));
252    }
253
254    #[tokio::test]
255    async fn returns_complete_user_guide() {
256        let output = execute(serde_json::json!({"topic": "guide", "query": "install-pi"})).await;
257        assert!(output.contains("rpi install-pi"));
258        assert!(output.contains("npm"));
259    }
260
261    #[tokio::test]
262    async fn returns_extension_authoring_practices() {
263        let output = execute(serde_json::json!({"topic": "authoring", "query": "rpi dev"})).await;
264        assert!(output.contains("rpi dev"));
265        assert!(output.contains("cdylib"));
266    }
267
268    #[tokio::test]
269    async fn returns_focused_search_results() {
270        let output = execute(serde_json::json!({
271            "topic": "extensions",
272            "query": "Node"
273        }))
274        .await;
275        assert!(output.contains("Node"));
276        assert!(output.contains("Matches for"));
277    }
278
279    #[tokio::test]
280    async fn rejects_unknown_topic() {
281        let error = DocsTool::new()
282            .execute(
283                "test",
284                serde_json::json!({"topic": "missing"}),
285                CancellationToken::new(),
286                Arc::new(|_| {}),
287            )
288            .await
289            .unwrap_err()
290            .to_string();
291        assert!(error.contains("Unknown rpi docs topic"));
292    }
293}