Skip to main content

tiny_agent_rs/tools/
jina.rs

1use super::Tool;
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use std::pin::Pin;
5
6/// Parameters accepted by the Jina reader tool
7#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
8pub struct JinaReaderParams {
9    /// Fully-qualified URL to fetch (e.g. <https://www.example.com>)
10    pub url: String,
11    /// When true, bypass cached snapshot
12    #[serde(default)]
13    pub no_cache: Option<bool>,
14}
15
16/// Response returned by Jina reader
17#[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/// Tool that calls the Jina reader API and returns markdown content
27#[derive(Debug, Clone)]
28pub struct JinaReaderTool {
29    api_key: String,
30    client: Client,
31}
32
33impl JinaReaderTool {
34    /// Create a new tool using the provided API key
35    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    /// Build the tool using the `JINA_API_KEY` environment variable
43    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}