2. Tools
A tool is a function the model can ask to call. You add one that lists the documents the assistant knows about.
Write the tool
docs-assistant/src/tools.rs
use async_trait::async_trait;
use beam_agents::{json_params, Tool, ToolSpec};
use beam_types::AgentInput;
use riverbase_core::RiverbaseResult;
use serde_json::{json, Value};
/// Lists the documents under the docs directory, optionally filtered by a word in the name.
pub struct ListDocuments {
pub names: Vec<String>,
}
#[async_trait]
impl Tool for ListDocuments {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "list_documents".into(),
description: "List the documentation files. Optionally filter by a word in the file name.".into(),
confirm_required: false,
parameters: json_params(json!({"filter": {"type": "string"}}), &[]),
}
}
async fn invoke(&self, args: Value, _input: &AgentInput) -> RiverbaseResult<Value> {
let filter = args.get("filter").and_then(Value::as_str).unwrap_or_default().to_lowercase();
let names: Vec<&String> = self
.names
.iter()
.filter(|name| name.to_lowercase().contains(&filter))
.collect();
Ok(json!({ "documents": names }))
}
}
specis what the model sees: the name, a description, and the arguments as JSON Schema.json_params(properties, required)builds the schema object.invokereceives the arguments the model chose and the request (AgentInput), and returns JSON. The runner sends that JSON back to the model as the tool result.- Return an error only for failures you cannot recover from: an error from
invokeends the whole run. For a bad argument, return a JSON message the model can act on instead.
Give it to the agent
Collect the file names at startup and pass the tool to the agent:
docs-assistant/src/main.rs
mod agents;
mod tools;
use std::sync::Arc;
use beam_agents::Tool;
use beam_core::{BeamConfig, RiverBeam};
fn document_names() -> std::io::Result<Vec<String>> {
let mut names = Vec::new();
for entry in std::fs::read_dir("docs")? {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "md") {
names.push(path.file_name().unwrap_or_default().to_string_lossy().into_owned());
}
}
names.sort();
Ok(names)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let tools: Vec<Arc<dyn Tool>> = vec![Arc::new(tools::ListDocuments { names: document_names()? })];
let beam = RiverBeam::new(BeamConfig::default());
beam.register_agent(Arc::new(agents::assistant(tools)));
// … invoke as in step 1, asking "Which documents mention sessions?"
Ok(())
}
Create a docs/ directory with a few .md files before you run it.
How the loop works
With a live model, a question such as "Which documents mention sessions?" goes like this:
- The runner sends the history and the tool specs to the model.
- The model answers with a tool call:
list_documentswith{"filter": "session"}. - The runner publishes a
toolframe, runs the tool, adds the result to the conversation, and asks the model again. - The model answers with text. The runner emits a
chunkframe and thedoneframe.
The loop allows 8 rounds before it stops with BEM-088. Each call is recorded in
AgentResult.tool_calls.
With the echo client, the model never calls tools; you see the echo instead.