oxi-agent 0.13.0

Agent runtime with tool-calling loop for AI coding assistants
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
/// GitHub search tool — search GitHub repositories, issues, and code via the GitHub REST API.
///
/// Features:
/// - Search repositories by topic, language, stars, etc.
/// - Sort by stars, forks, or recently updated
/// - Optional GitHub token for higher rate limits (via GITHUB_TOKEN env var)
/// - Structured JSON results — no HTML scraping
/// - Result caching with the shared SearchCache

use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
use super::search_cache::{SearchCache, SearchResult};
use super::http_client::shared_http_client;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::oneshot;

/// Maximum results to return by default.
const DEFAULT_MAX_RESULTS: usize = 10;

/// Maximum results allowed (GitHub API max is 100 per page).
const MAX_RESULTS: usize = 30;

// ── GitHub API response types ─────────────────────────────────────

/// Top-level GitHub search response.
#[derive(Debug, Deserialize)]

struct GitHubSearchResponse {
    total_count: u64,
    _incomplete_results: bool,
    items: Vec<GitHubRepo>,
}

/// A single repository from GitHub search.
#[derive(Debug, Deserialize)]
struct GitHubRepo {
    full_name: String,
    html_url: String,
    description: Option<String>,
    language: Option<String>,
    stargazers_count: u64,
    forks_count: u64,
    open_issues_count: u64,
    updated_at: String,

    _archived: bool,
    topics: Vec<String>,
    license: Option<GitHubLicense>,
}

#[derive(Debug, Deserialize)]
struct GitHubLicense {
    spdx_id: Option<String>,
    name: Option<String>,
}

// ── GitHub search result (our public type) ────────────────────────

/// A single GitHub repository result.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GitHubSearchResult {
    /// Full repo name (e.g. "owner/repo").
    pub full_name: String,
    /// Repository URL.
    pub url: String,
    /// Repository description.
    pub description: String,
    /// Primary programming language.
    pub language: String,
    /// Star count.
    pub stars: u64,
    /// Fork count.
    pub forks: u64,
    /// Open issues count.
    pub open_issues: u64,
    /// Last update timestamp.
    pub updated_at: String,
    /// Repository topics/tags.
    pub topics: Vec<String>,
    /// License name.
    pub license: String,
}

impl From<&GitHubSearchResult> for SearchResult {
    fn from(r: &GitHubSearchResult) -> Self {
        SearchResult {
            title: r.full_name.clone(),
            url: r.url.clone(),
            snippet: r.description.clone(),
            engines: vec!["GitHub".to_string()],
            score: r.stars as f64,
        }
    }
}

// ── API call ──────────────────────────────────────────────────────

/// Resolve a GitHub API token from the environment.
fn resolve_github_token() -> Option<String> {
    // 1. GITHUB_SEARCH_TOKEN (explicit for this tool)
    std::env::var("GITHUB_SEARCH_TOKEN")
        .ok()
        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
        .or_else(|| std::env::var("GH_TOKEN").ok())
}

/// Search GitHub repositories via the REST API.
async fn search_github_repos(
    query: &str,
    sort: &str,
    order: &str,
    limit: usize,
    language: Option<&str>,
) -> Result<(u64, Vec<GitHubSearchResult>), ToolError> {
    let mut url = format!(
        "https://api.github.com/search/repositories?q={}&sort={}&order={}&per_page={}",
        urlencoding(query),
        sort,
        order,
        limit.min(MAX_RESULTS),
    );

    // Add language filter if specified
    if let Some(lang) = language {
        // Append language:xxx to the query
        url = format!(
            "https://api.github.com/search/repositories?q={}+language%3A{}&sort={}&order={}&per_page={}",
            urlencoding(query),
            urlencoding(lang),
            sort,
            order,
            limit.min(MAX_RESULTS),
        );
    }

    let mut builder = shared_http_client()
        .get(&url)
        .header("Accept", "application/vnd.github.v3+json")
        .header("User-Agent", "oxi-agent");

    // Attach token if available (raises rate limit from 10/min to 5000/hr)
    if let Some(token) = resolve_github_token() {
        builder = builder.header("Authorization", format!("Bearer {}", token));
    }

    let response = builder
        .send()
        .await
        .map_err(|e| format!("GitHub API request failed: {}", e))?;

    let status = response.status();
    if status.as_u16() == 403 {
        let body = response.text().await.unwrap_or_default();
        return Err(format!(
            "GitHub API rate limit exceeded. Set GITHUB_TOKEN env var for higher limits. Body: {}",
            body.chars().take(200).collect::<String>()
        ));
    }
    if !status.is_success() {
        let body = response.text().await.unwrap_or_default();
        return Err(format!(
            "GitHub API returned status {}: {}",
            status,
            body.chars().take(300).collect::<String>()
        ));
    }

    let search_response: GitHubSearchResponse = response
        .json()
        .await
        .map_err(|e| format!("Failed to parse GitHub response: {}", e))?;

    let results = search_response
        .items
        .into_iter()
        .map(|repo| GitHubSearchResult {
            full_name: repo.full_name,
            url: repo.html_url,
            description: repo.description.unwrap_or_default(),
            language: repo.language.unwrap_or_default(),
            stars: repo.stargazers_count,
            forks: repo.forks_count,
            open_issues: repo.open_issues_count,
            updated_at: repo.updated_at,
            topics: repo.topics,
            license: repo
                .license
                .and_then(|l| l.spdx_id.or(l.name))
                .unwrap_or_default(),
        })
        .collect();

    Ok((search_response.total_count, results))
}

/// URL-encode a string for query parameters.
fn urlencoding(s: &str) -> String {
    let mut result = String::with_capacity(s.len() * 3);
    for byte in s.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                result.push(byte as char);
            }
            _ => {
                result.push('%');
                result.push_str(&format!("{:02X}", byte));
            }
        }
    }
    result
}

// ── Formatting ────────────────────────────────────────────────────

/// Format GitHub search results for display.
fn format_github_results(total: u64, results: &[GitHubSearchResult]) -> String {
    if results.is_empty() {
        return "No repositories found.".to_string();
    }

    let mut output = format!("Found {} repositories (showing {}):\n\n", total, results.len());

    for (i, r) in results.iter().enumerate() {
        let stars = if r.stars >= 1000 {
            format!("{:.1}k", r.stars as f64 / 1000.0)
        } else {
            r.stars.to_string()
        };

        let desc = if r.description.chars().count() > 150 {
            let truncated: String = r.description.chars().take(150).collect();
            format!("{}...", truncated)
        } else {
            r.description.clone()
        };

        output.push_str(&format!(
            "{}. **{}** ⭐{}\n   {}\n   {} {} | 🔀 {} forks | 📦 {} issues\n   Updated: {}\n",
            i + 1,
            r.full_name,
            stars,
            r.url,
            desc,
            if r.language.is_empty() {
                "Unknown".to_string()
            } else {
                r.language.clone()
            },
            r.forks,
            r.open_issues,
            &r.updated_at[..10], // Just the date part
        ));

        if !r.topics.is_empty() {
            output.push_str(&format!(
                "   Topics: {}\n",
                r.topics.join(", ")
            ));
        }

        if !r.license.is_empty() {
            output.push_str(&format!("   License: {}\n", r.license));
        }

        output.push('\n');
    }

    output
}

// ── GitHubSearchTool ──────────────────────────────────────────────

/// GitHub repository search tool using the GitHub REST API.
pub struct GitHubSearchTool {
    cache: Arc<SearchCache>,
}

impl GitHubSearchTool {
    /// Create a new GitHubSearchTool with the given search cache.
    pub fn new(cache: Arc<SearchCache>) -> Self {
        Self { cache }
    }
}

#[async_trait]
impl AgentTool for GitHubSearchTool {
    fn name(&self) -> &str {
        "github_search"
    }

    fn label(&self) -> &str {
        "GitHub Search"
    }

    fn description(&self) -> &str {
        "Search GitHub repositories by query. Returns repos with stars, forks, language, description, and topics. Supports sorting by stars, forks, or recently updated. No API key required (set GITHUB_TOKEN for higher rate limits)."
    }

    fn parameters_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Search query (e.g. 'rust web framework', 'machine learning', 'owner:mariozechner')"
                },
                "sort": {
                    "type": "string",
                    "description": "Sort results by: 'stars' (default), 'forks', or 'updated'",
                    "enum": ["stars", "forks", "updated"],
                    "default": "stars"
                },
                "order": {
                    "type": "string",
                    "description": "Sort order: 'desc' (default) or 'asc'",
                    "enum": ["desc", "asc"],
                    "default": "desc"
                },
                "language": {
                    "type": "string",
                    "description": "Filter by programming language (e.g. 'rust', 'python', 'typescript')"
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum number of results to return (default: 10, max: 30)",
                    "default": 10
                }
            },
            "required": ["query"]
        })
    }

    async fn execute(
        &self,
        _tool_call_id: &str,
        params: Value,
        _signal: Option<oneshot::Receiver<()>>,
        _ctx: &ToolContext,
    ) -> Result<AgentToolResult, ToolError> {
        let query = params["query"]
            .as_str()
            .ok_or_else(|| "Missing required parameter: query".to_string())?;

        let sort = params["sort"].as_str().unwrap_or("stars");
        let sort = match sort {
            "forks" | "updated" => sort,
            _ => "stars",
        };

        let order = params["order"].as_str().unwrap_or("desc");
        let order = match order {
            "asc" => "asc",
            _ => "desc",
        };

        let language = params["language"].as_str();

        let limit = params["limit"]
            .as_u64()
            .unwrap_or(DEFAULT_MAX_RESULTS as u64)
            .min(MAX_RESULTS as u64) as usize;

        let (total, results) =
            search_github_repos(query, sort, order, limit, language).await?;

        if results.is_empty() {
            return Ok(AgentToolResult::success(format!(
                "No GitHub repositories found for: {}",
                query
            )));
        }

        // Cache results
        let search_id = self.cache.insert(
            &format!("github:{}", query),
            results.iter().map(|r| r.into()).collect(),
        );

        let output = format_github_results(total, &results);

        let results_json: Vec<Value> = results
            .iter()
            .map(|r| {
                json!({
                    "full_name": r.full_name,
                    "url": r.url,
                    "description": r.description,
                    "language": r.language,
                    "stars": r.stars,
                    "forks": r.forks,
                    "open_issues": r.open_issues,
                    "updated_at": r.updated_at,
                    "topics": r.topics,
                    "license": r.license
                })
            })
            .collect();

        Ok(AgentToolResult::success(output).with_metadata(json!({
            "results": results_json,
            "query": query,
            "searchId": search_id,
            "totalCount": total,
            "resultCount": results.len()
        })))
    }
}

// ── Tests ─────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_urlencoding() {
        assert_eq!(urlencoding("hello world"), "hello%20world");
        assert_eq!(urlencoding("rust&cargo"), "rust%26cargo");
        assert_eq!(urlencoding("c++"), "c%2B%2B");
    }

    #[test]
    fn test_format_github_results_empty() {
        assert_eq!(format_github_results(0, &[]), "No repositories found.");
    }

    #[test]
    fn test_format_github_results() {
        let results = vec![GitHubSearchResult {
            full_name: "rust-lang/rust".to_string(),
            url: "https://github.com/rust-lang/rust".to_string(),
            description: "Empowering everyone to build reliable and efficient software.".to_string(),
            language: "Rust".to_string(),
            stars: 95000,
            forks: 12000,
            open_issues: 9000,
            updated_at: "2026-05-08T12:00:00Z".to_string(),
            topics: vec!["programming-language".to_string(), "systems".to_string()],
            license: "MIT/Apache-2.0".to_string(),
        }];
        let formatted = format_github_results(1, &results);
        assert!(formatted.contains("**rust-lang/rust**"));
        assert!(formatted.contains("95.0k"));
        assert!(formatted.contains("Rust"));
        assert!(formatted.contains("Topics: programming-language, systems"));
    }

    #[test]
    fn test_format_stars_under_1k() {
        let results = vec![GitHubSearchResult {
            full_name: "test/repo".to_string(),
            url: "https://github.com/test/repo".to_string(),
            description: "A test".to_string(),
            language: "Python".to_string(),
            stars: 500,
            forks: 20,
            open_issues: 3,
            updated_at: "2026-05-01T00:00:00Z".to_string(),
            topics: vec![],
            license: String::new(),
        }];
        let formatted = format_github_results(1, &results);
        assert!(formatted.contains("⭐500"));
    }

    #[test]
    fn test_schema() {
        let cache = Arc::new(SearchCache::new());
        let tool = GitHubSearchTool::new(cache);
        let schema = tool.parameters_schema();
        assert_eq!(schema["type"], "object");
        assert!(schema["properties"]["query"].is_object());
        assert!(schema["properties"]["sort"].is_object());
        assert!(schema["properties"]["language"].is_object());
        assert!(schema["required"].as_array().unwrap().contains(&json!("query")));
    }

    #[tokio::test]
    async fn test_github_search_live() {
        // Integration test — requires network. Skip if offline.
        let result = search_github_repos("rust web framework", "stars", "desc", 3, None).await;
        if let Ok((total, results)) = result {
            assert!(total > 0);
            assert!(!results.is_empty());
            assert!(results[0].stars > 0);
            assert!(results[0].url.starts_with("https://github.com/"));
        }
    }
}