1use 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 #[serde(default)]
73 pub topic: Option<String>,
74 #[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}