Skip to content

A Research Agent

This example builds an agent that acts, not just chats. It has two tools: a custom calculator and the built-in web-fetch tool. Ask it a question that needs current information or arithmetic, and it works through the agentic loop — calling tools, reading results, and composing an answer.

Terminal window
cargo new kova-research && cd kova-research
cargo add kova-sdk --features web-tools # enables the web fetch tool
cargo add tokio --features macros,rt-multi-thread
cargo add async-trait serde_json
src/main.rs
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use kova_sdk::prelude::*;
use kova_sdk::provider::openai::{OpenAiCompatibleProvider, OpenAiProviderConfig};
use kova_sdk::tools::{ToolPolicy, WebPolicy, register_all_tools_with_policy};
use serde_json::{json, Value};
fn user_message(text: &str) -> ConversationMessage {
ConversationMessage { role: Role::User, content: vec![ContentBlock::Text { text: text.into() }] }
}
// --- A custom tool: evaluate a simple arithmetic expression ---------------
struct Calculator;
#[async_trait]
impl Tool for Calculator {
fn name(&self) -> &str { "calculator" }
fn description(&self) -> &str {
"Evaluate a basic arithmetic expression of two numbers. \
Use this for any exact arithmetic instead of computing it yourself."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"a": { "type": "number" },
"op": { "type": "string", "enum": ["+", "-", "*", "/"] },
"b": { "type": "number" }
},
"required": ["a", "op", "b"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult, KovaError> {
let a = args["a"].as_f64();
let b = args["b"].as_f64();
let op = args["op"].as_str();
let (Some(a), Some(op), Some(b)) = (a, op, b) else {
// in-band error → the model can fix its arguments and retry
return Ok(ToolResult {
content: "need numeric 'a', 'b' and an 'op' of + - * /".into(),
is_error: true,
});
};
let result = match op {
"+" => a + b,
"-" => a - b,
"*" => a * b,
"/" if b != 0.0 => a / b,
"/" => return Ok(ToolResult { content: "division by zero".into(), is_error: true }),
_ => return Ok(ToolResult { content: "unknown operator".into(), is_error: true }),
};
Ok(ToolResult { content: result.to_string(), is_error: false })
}
}
#[tokio::main]
async fn main() -> Result<(), KovaError> {
// --- Provider ----------------------------------------------------------
let config = OpenAiProviderConfig::new("https://api.openai.com", "gpt-4o")
.with_api_key(std::env::var("OPENAI_API_KEY").expect("set OPENAI_API_KEY"));
let provider = Arc::new(OpenAiCompatibleProvider::new(config)?);
// --- Built-in web tool, confined by a policy ---------------------------
// No filesystem access; web fetch limited to public HTTPS hosts.
let policy = Arc::new(ToolPolicy {
workspace_root: None,
protected_paths: vec![],
shell_timeout: Duration::from_secs(10),
web: WebPolicy { https_only: true, allow_private_hosts: false, ..WebPolicy::default() },
});
// --- Agent -------------------------------------------------------------
let mut builder = AgentBuilder::new()
.provider(provider)
.system_prompt(
"You are a research assistant. Use the tools available to you to \
gather facts and do exact arithmetic before answering. Cite what \
you fetched.",
)
.tool(Arc::new(Calculator))
.max_iterations(8); // allow a few tool rounds for multi-step questions
// Register the built-in tools (fetch_webpage, etc.) alongside the calculator.
for tool in register_all_tools_with_policy(policy) {
builder = builder.tool(tool);
}
let agent = builder.build()?;
// --- Ask something that needs tools ------------------------------------
let question = "Fetch https://www.rust-lang.org and tell me the current \
tagline. Also, what is 1234 * 5678?";
let history = vec![user_message(question)];
let response = agent.run(&history).await?;
println!("\n=== Answer ===\n{}", response.text);
println!(
"\n[used {} LLM calls, {} tokens total]",
response.llm_calls, response.usage.total_tokens
);
Ok(())
}

The agent typically:

  1. calls fetch_webpage("https://www.rust-lang.org") — the built-in tool returns readability-extracted text,
  2. calls calculator({ a: 1234, op: "*", b: 5678 }) — your tool returns 7006652,
  3. composes a final answer citing the fetched page and the exact product.

You wrote one tool and a policy; Kova ran the multi-step loop, executing the two tools (concurrently where possible) and feeding results back to the model.

LLMs are unreliable at exact arithmetic. Giving the model a calculator tool — and a system prompt telling it to use the tool rather than compute in its head — is a classic pattern: offload anything that needs precision to deterministic code. The same idea applies to dates, unit conversion, lookups, and database queries.

Web and filesystem tools can be abused by a confused or adversarial model. This example already:

  • sets https_only and rejects private hosts (SSRF defense),
  • omits a workspace_root because we registered no filesystem writes it needs.

For anything that writes or runs shell, add a ToolApprovalHandler so each sensitive call is gated — belt and suspenders on top of the policy.