Chuyển tới nội dung chính

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 }))
}
}
  • spec is what the model sees: the name, a description, and the arguments as JSON Schema. json_params(properties, required) builds the schema object.
  • invoke receives 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 invoke ends 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:

  1. The runner sends the history and the tool specs to the model.
  2. The model answers with a tool call: list_documents with {"filter": "session"}.
  3. The runner publishes a tool frame, runs the tool, adds the result to the conversation, and asks the model again.
  4. The model answers with text. The runner emits a chunk frame and the done frame.

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.

Next: answer from the documents themselves.