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: "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 #[serde(default)]
79 pub topic: Option<String>,
80 #[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 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}