use anda_core::{
BoxError, FunctionDefinition, HttpFeatures, Json, Resource, Tool, ToolOutput, gen_schema_for,
};
use encoding_rs::Encoding;
use http::header;
use ic_auth_types::ByteBufB64;
use mime::Mime;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::context::BaseCtx;
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct FetchWebResourcesArgs {
pub url: String,
}
#[derive(Debug, Clone)]
pub struct FetchWebResourcesTool {
schema: Json,
}
impl Default for FetchWebResourcesTool {
fn default() -> Self {
Self::new()
}
}
impl FetchWebResourcesTool {
pub const NAME: &'static str = "fetch_web_resources";
pub fn new() -> Self {
let schema = gen_schema_for::<FetchWebResourcesArgs>();
Self { schema }
}
pub async fn fetch(
ctx: &impl HttpFeatures,
url: &str,
) -> Result<(header::HeaderMap, Vec<u8>), BoxError> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::ACCEPT,
"application/json, text/*, */*;q=0.9"
.parse()
.expect("invalid header value"),
);
let response = ctx
.https_call(url, http::Method::GET, Some(headers), None)
.await?;
if !response.status().is_success() {
return Err(format!("Fetch failed with status: {}", response.status()).into());
}
let headers = response.headers().clone();
let body = response
.bytes()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
Ok((headers, body.to_vec()))
}
pub async fn fetch_as_text(ctx: &impl HttpFeatures, url: &str) -> Result<String, BoxError> {
let (headers, body) = Self::fetch(ctx, url).await?;
match Self::decode_text(&headers, &body) {
Some(text) => Ok(text),
None => match String::from_utf8(body) {
Ok(text) => Ok(text),
Err(e) => Ok(ByteBufB64(e.into_bytes()).to_string()),
},
}
}
pub async fn fetch_as_bytes(
ctx: &impl HttpFeatures,
url: &str,
) -> Result<ByteBufB64, BoxError> {
let (headers, body) = Self::fetch(ctx, url).await?;
match Self::decode_text(&headers, &body) {
Some(text) => Ok(ByteBufB64(text.into_bytes())),
None => Ok(ByteBufB64(body)),
}
}
pub fn decode_text(headers: &header::HeaderMap, data: &[u8]) -> Option<String> {
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<Mime>().ok());
if let Some(encoding_name) = content_type
.as_ref()
.and_then(|mime| mime.get_param("charset").map(|charset| charset.as_str()))
&& let Some(encoding) = Encoding::for_label(encoding_name.as_bytes())
{
let (text, _, had_errors) = encoding.decode(data);
if !had_errors {
return Some(text.into_owned());
}
}
None
}
}
impl Tool<BaseCtx> for FetchWebResourcesTool {
type Args = FetchWebResourcesArgs;
type Output = String;
fn name(&self) -> String {
Self::NAME.to_string()
}
fn description(&self) -> String {
"Fetches resources from a given URL and returns the content as text (base64-url encoded if not UTF-8)".to_string()
}
fn definition(&self) -> FunctionDefinition {
FunctionDefinition {
name: self.name(),
description: self.description(),
parameters: self.schema.clone(),
strict: Some(true),
}
}
async fn call(
&self,
ctx: BaseCtx,
args: Self::Args,
_resources: Vec<Resource>,
) -> Result<ToolOutput<Self::Output>, BoxError> {
let text = FetchWebResourcesTool::fetch_as_text(&ctx, &args.url).await?;
Ok(ToolOutput::new(text))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::EngineBuilder;
#[tokio::test]
#[ignore]
async fn test_fetch_resources_tool() {
let tool = FetchWebResourcesTool::new();
let definition = tool.definition();
assert_eq!(tool.name(), "fetch_resources");
println!("{}", serde_json::to_string_pretty(&definition).unwrap());
let ctx = EngineBuilder::new().mock_ctx();
let res = tool
.call(
ctx.base,
FetchWebResourcesArgs {
url: "https://anda.ai".to_string(),
},
Vec::new(),
)
.await
.unwrap();
print!("{:?}", res);
}
}