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 扩展开发最佳实践:ABI v3/v2 入口、工具生命周期、事件处理器、资源发现、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"],
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 扩展编写(ABI v3/v2、工具生命周期、事件处理器)与 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("- extensions:"));
260 assert!(!output.contains("- compatibility:"));
261 }
262
263 #[tokio::test]
264 async fn returns_rust_debugging_guide() {
265 let output = execute(serde_json::json!({"topic": "debugging", "query": "rpi dev"})).await;
266 assert!(output.contains("rpi dev"));
267 assert!(output.contains("cdylib"));
268 }
269
270 #[tokio::test]
271 async fn returns_complete_user_guide() {
272 let output = execute(serde_json::json!({"topic": "guide", "query": "cdylib"})).await;
273 assert!(output.contains("cdylib"));
274 assert!(output.contains("Rust"));
275 }
276
277 #[tokio::test]
278 async fn returns_extension_authoring_practices() {
279 let output = execute(serde_json::json!({"topic": "authoring", "query": "rpi dev"})).await;
280 assert!(output.contains("rpi dev"));
281
282 let cdylib = execute(serde_json::json!({"topic": "authoring", "query": "cdylib"})).await;
286 assert!(cdylib.contains("cdylib"));
287 }
288
289 #[tokio::test]
290 async fn returns_agent_project_guide() {
291 let output = execute(serde_json::json!({
292 "topic": "agent",
293 "query": "AgentBuilder"
294 }))
295 .await;
296 assert!(output.contains("AgentBuilder"));
297 assert!(output.contains("Matches for"));
298 }
299
300 #[tokio::test]
301 async fn agent_aliases_resolve() {
302 let output = execute(serde_json::json!({"topic": "create-agent"})).await;
303 assert!(output.contains("rpi Agent 项目结构创建指南"));
304 }
305
306 #[tokio::test]
307 async fn rejects_unknown_topic() {
308 let error = DocsTool::new()
309 .execute(
310 "test",
311 serde_json::json!({"topic": "missing"}),
312 CancellationToken::new(),
313 Arc::new(|_| {}),
314 )
315 .await
316 .unwrap_err()
317 .to_string();
318 assert!(error.contains("Unknown rpi docs topic"));
319 }
320}