tiny_agent_rs/tools/
jina.rs1use super::Tool;
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use std::pin::Pin;
5
6#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
8pub struct JinaReaderParams {
9 pub url: String,
11 #[serde(default)]
13 pub no_cache: Option<bool>,
14}
15
16#[derive(Debug, Serialize, Deserialize)]
18pub struct JinaReaderResponse {
19 pub title: Option<String>,
20 pub url_source: Option<String>,
21 pub published_time: Option<String>,
22 pub markdown: Option<String>,
23 pub raw: String,
24}
25
26#[derive(Debug, Clone)]
28pub struct JinaReaderTool {
29 api_key: String,
30 client: Client,
31}
32
33impl JinaReaderTool {
34 pub fn new(api_key: impl Into<String>) -> Self {
36 Self {
37 api_key: api_key.into(),
38 client: Client::new(),
39 }
40 }
41
42 pub fn from_env() -> Result<Self, crate::AgentError> {
44 let api_key = std::env::var("JINA_API_KEY")
45 .map_err(|_| crate::AgentError::Config("Missing JINA_API_KEY env var".to_string()))?;
46 Ok(Self::new(api_key))
47 }
48}
49
50impl Tool for JinaReaderTool {
51 fn name(&self) -> &'static str {
52 "jina_reader"
53 }
54
55 fn description(&self) -> &'static str {
56 "Fetch markdown content for a URL using the Jina reader API"
57 }
58
59 fn parameters_schema(&self) -> serde_json::Value {
60 serde_json::json!({
61 "type": "object",
62 "properties": {
63 "url": {
64 "type": "string",
65 "description": "Fully qualified URL to fetch"
66 },
67 "no_cache": {
68 "type": "boolean",
69 "description": "Set true to bypass cached snapshot"
70 }
71 },
72 "required": ["url"]
73 })
74 }
75
76 fn execute(
77 &self,
78 parameters: serde_json::Value,
79 ) -> Pin<
80 Box<
81 dyn std::future::Future<Output = Result<serde_json::Value, crate::AgentError>>
82 + Send
83 + '_,
84 >,
85 > {
86 let client = self.client.clone();
87 let api_key = self.api_key.clone();
88
89 Box::pin(async move {
90 let params: JinaReaderParams = serde_json::from_value(parameters).map_err(|err| {
91 crate::AgentError::ToolExecution(format!("Invalid parameters: {}", err))
92 })?;
93
94 let target_url = if params.url.starts_with("https://r.jina.ai/") {
95 params.url
96 } else {
97 format!("https://r.jina.ai/{}", params.url)
98 };
99
100 let mut request = client
101 .get(&target_url)
102 .header("Authorization", format!("Bearer {}", api_key));
103
104 if params.no_cache.unwrap_or(false) {
105 request = request.header("Cache-Control", "no-cache");
106 }
107
108 let response = request.send().await.map_err(|err| {
109 crate::AgentError::ToolExecution(format!("Failed to call Jina reader: {}", err))
110 })?;
111
112 if !response.status().is_success() {
113 return Err(crate::AgentError::ToolExecution(format!(
114 "Jina reader returned status {}",
115 response.status()
116 )));
117 }
118
119 let body = response.text().await.map_err(|err| {
120 crate::AgentError::ToolExecution(format!("Failed to read Jina response: {}", err))
121 })?;
122
123 let parsed = parse_jina_response(&body);
124
125 serde_json::to_value(parsed).map_err(|err| {
126 crate::AgentError::ToolExecution(format!("Failed to serialize response: {}", err))
127 })
128 })
129 }
130}
131
132fn parse_jina_response(raw: &str) -> JinaReaderResponse {
133 let mut title = None;
134 let mut url_source = None;
135 let mut published_time = None;
136 let mut markdown_lines: Vec<String> = Vec::new();
137 let mut in_markdown = false;
138
139 for line in raw.lines() {
140 if let Some(value) = line.strip_prefix("Title: ") {
141 title = Some(value.trim().to_string());
142 continue;
143 }
144
145 if let Some(value) = line.strip_prefix("URL Source: ") {
146 url_source = Some(value.trim().to_string());
147 continue;
148 }
149
150 if let Some(value) = line.strip_prefix("Published Time: ") {
151 published_time = Some(value.trim().to_string());
152 continue;
153 }
154
155 if let Some(value) = line.strip_prefix("Markdown Content:") {
156 in_markdown = true;
157 let trimmed = value.trim_start();
158 if !trimmed.is_empty() {
159 markdown_lines.push(trimmed.to_string());
160 }
161 continue;
162 }
163
164 if in_markdown {
165 markdown_lines.push(line.to_string());
166 }
167 }
168
169 JinaReaderResponse {
170 title,
171 url_source,
172 published_time,
173 markdown: if markdown_lines.is_empty() {
174 None
175 } else {
176 Some(markdown_lines.join("\n"))
177 },
178 raw: raw.to_string(),
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn test_parse_jina_response() {
188 let raw =
189 "Title: Example\nURL Source: https://example.com\nMarkdown Content:\nLine 1\nLine 2";
190 let parsed = parse_jina_response(raw);
191 assert_eq!(parsed.title.as_deref(), Some("Example"));
192 assert_eq!(parsed.url_source.as_deref(), Some("https://example.com"));
193 assert_eq!(parsed.markdown.as_deref(), Some("Line 1\nLine 2"));
194 assert!(parsed.raw.contains("Markdown Content"));
195 }
196}