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 开发、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 #[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_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
185fn 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
202fn 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
248fn 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 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 #[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 assert!(query_terms("/ , ; : |").is_empty());
444 }
445
446 #[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 let cjk = query_terms("资源发现 skills");
457 assert_eq!(cjk, vec!["资源发现", "skills"]);
458 assert!(line_matches("使用 资源发现 和 Skills 目录", &cjk));
459 }
460
461 #[tokio::test]
464 async fn review_issue14_no_match_falls_back_to_another_topic() {
465 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 #[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 #[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}