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.
What it demonstrates
Section titled “What it demonstrates”- A custom tool (Writing a Custom Tool).
- A built-in tool with a security policy (Built-in Tools & ToolPolicy).
- Reading the full
AgentResponseto report tool usage and token counts.
Set up
Section titled “Set up”cargo new kova-research && cd kova-researchcargo add kova-sdk --features web-tools # enables the web fetch toolcargo add tokio --features macros,rt-multi-threadcargo add async-trait serde_jsonThe program
Section titled “The program”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(())}What happens when you run it
Section titled “What happens when you run it”The agent typically:
- calls
fetch_webpage("https://www.rust-lang.org")— the built-in tool returns readability-extracted text, - calls
calculator({ a: 1234, op: "*", b: 5678 })— your tool returns7006652, - 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.
Why the calculator?
Section titled “Why the calculator?”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.
Make it safer
Section titled “Make it safer”Web and filesystem tools can be abused by a confused or adversarial model. This example already:
- sets
https_onlyand rejects private hosts (SSRF defense), - omits a
workspace_rootbecause 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.
- Turn this into an interactive loop by combining it with A CLI Chatbot.
- Split research and writing across specialists in A Multi-Agent Pipeline.