Agents, tools and skills
An agent is anything that implements the Agent trait from beam_agents. You register agents on
RiverBeam, and requests pick one by agent_name.
The Agent trait
#[async_trait]
pub trait Agent: Send + Sync {
fn metadata(&self) -> AgentMetadata;
async fn stream(
&self,
input: &AgentInput,
context: &AgentContext,
) -> RiverbaseResult<Vec<StreamChunk>>;
async fn health_check(&self) -> bool {
true
}
}
metadatadescribes the agent:name,description,supported_modalities,output_modalities,tags, andversion. A request whose content uses a modality the agent does not list fails withBEM-040. The router also reads the tags. With the built-in text-only model, any non-text content fails earlier, at routing, withBEM-041.streamreturns the agent's frames. The last frame must have typedoneand carry theAgentResult; otherwise the pipeline fails withBEM-086. Anerrorframe fails withBEM-087.inputis the request (content,session_id,run_id,metadata,auth_context,actions).contextholds the conversation history, the resolvedModelConfig, and the request metadata.
Register agents on the pipeline:
use std::sync::Arc;
beam.register_agent(Arc::new(my_agent));
beam.register_echo_agent("assistant"); // EchoAgent: replies "Echo: <input>"
list_agents() returns the metadata of every registered agent, and
registry().get_metadata(name) returns one (BEM-020 if it is not registered).
RigAgentRunner
RigAgentRunner is the built-in tool-loop agent. It sends the conversation and the tool specs to a
completion client, runs any tool calls it gets back, and repeats until the model answers with
text.
use std::sync::Arc;
use beam_agents::{sandbox_tools, CompletionClient, EchoClient, OpenAiCompatible, RigAgentRunner};
use beam_sandbox::{SandboxRoot, WorkingTree};
// A live model when BEAM_API_KEY (or NVIDIA_API_KEY) is set; an echo client otherwise.
let client: Arc<dyn CompletionClient> = match OpenAiCompatible::from_env() {
Some(client) => Arc::new(client),
None => Arc::new(EchoClient),
};
// Tools work inside one jailed directory. "main" and "master" can never be pushed.
let tree = WorkingTree::new(
SandboxRoot::new("/srv/agent-work/docs")?,
vec!["main".into(), "master".into()],
);
tree.init()?;
let author = RigAgentRunner::for_skill("author", client, sandbox_tools(Some(tree)));
beam.register_agent(Arc::new(author));
init creates an empty repository. Before the agent can push, the tree needs a first commit, a
branch of its own (checkout_branch(name, true)), and an origin (add_origin(url)). Push sends no
credentials, so use a remote that needs none. Tutorial step 5 sets this
up.
How a run goes:
- It publishes a
placeholderand athinkingframe. - It calls the client with the history (prefixed by the skill's system prompt) and the tool specs.
- On tool calls, it runs each tool, records a
ToolCallRecord, adds atoolframe, and loops. It stops withBEM-088after 8 rounds, and fails withBEM-089when the model names a tool it does not have. An error from a tool ends the whole run; it is not passed back to the model. - On text, it emits a
chunkframe and thedoneframe.
Before running a tool whose spec has confirm_required: true, it returns an interrupted result
instead, so a person can approve it. See
Human approval.
The runner skips the approval step when the request has metadata.confirm set to true. Request
metadata comes from the client, so strip confirm in your API layer unless the caller is allowed to
approve their own actions.
Constructors:
| Constructor | Tools the agent gets |
|---|---|
RigAgentRunner::for_skill(skill_name, client, tools) | The tools you pass that appear in the skill's allowlist. An unknown skill name gets an empty system prompt and all the tools. |
RigAgentRunner::new(name, client, tools, skill) | All the tools you pass; the skill supplies only the system prompt. |
Completion clients
CompletionClient is the model boundary:
#[async_trait]
pub trait CompletionClient: Send + Sync {
async fn complete(&self, messages: &[Value], tools: &[ToolSpec]) -> RiverbaseResult<Completion>;
}
Completion is either Text(String) or ToolCalls(Vec<PendingToolCall>). Messages use the OpenAI
chat format.
| Client | Behaviour |
|---|---|
OpenAiCompatible | POST {base_url}/chat/completions with bearer auth. Works with any OpenAI-compatible endpoint. |
EchoClient | Echoes the last user message. No network. |
ScriptedClient::new(steps) | Returns the given completions in order, for tests. Fails with BEM-081 when it runs out. |
OpenAiCompatible::new(base_url, api_key, model) builds a client directly. from_env() reads:
| Variable | Default |
|---|---|
BEAM_API_KEY, then GFS_BEAM_API_KEY, then NVIDIA_API_KEY | none; from_env returns None without a key |
BEAM_API_BASE_URL, then GFS_BEAM_BASE_URL | https://integrate.api.nvidia.com/v1 |
BEAM_MODEL, then GFS_BEAM_MODEL | deepseek-ai/deepseek-v4-pro-0813 |
Requests use temperature 1.0, top_p 0.95, max_tokens 4096 (change it with
with_max_tokens), a 60-second timeout, and the extra body
{"chat_template_kwargs": {"thinking": false}}. Replace the extra body with with_extra_body.
Never commit API keys.
The client sends its own model. The model the router
resolves reaches the agent in context.model_config_resolved, and RigAgentRunner does not use it.
Tools
A tool implements Tool:
#[async_trait]
pub trait Tool: Send + Sync {
fn spec(&self) -> ToolSpec;
async fn invoke(&self, args: Value, input: &AgentInput) -> RiverbaseResult<Value>;
}
ToolSpec has a name, a description, confirm_required, and parameters (a JSON Schema
object). json_params(properties, required) builds the schema.
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};
pub struct WordCount;
#[async_trait]
impl Tool for WordCount {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "word_count".into(),
description: "Count the words in a text.".into(),
confirm_required: false,
parameters: json_params(json!({"text": {"type": "string"}}), &["text"]),
}
}
async fn invoke(&self, args: Value, _input: &AgentInput) -> RiverbaseResult<Value> {
let text = args.get("text").and_then(Value::as_str).unwrap_or_default();
Ok(json!({ "words": text.split_whitespace().count() }))
}
}
Sandbox tools
sandbox_tools(tree) returns nine tools that work inside a beam_sandbox working tree:
| Tool | Arguments | Notes |
|---|---|---|
list_tree | path (optional) | Sorted file list; skips .git |
read_file | path | |
write_file | path, body | Creates parent directories |
git_status | ||
git_diff | ||
git_log | limit (default 20) | |
git_add | path | "" or "." stages everything |
git_commit | message | Returns the commit id |
git_push | ref (optional) | confirm_required; pushes to origin; protected refs fail with BEM-328 |
SandboxRoot rejects absolute paths, .., and anything that resolves outside the root
(BEM-300 to BEM-305). WorkingTree adds git through libgit2, and its protected_refs can never
be pushed.
With sandbox_tools(None), the tools read the root directory from the request's
metadata.sandbox_root, and the protected refs from metadata.protected_refs (default main and
master). A client could then choose any directory on the host. In a service, always pass
Some(tree) with a root you control.
Skills
A Skill is a name, a system prompt, and a tool allowlist. builtin_skills() lists them and
skill_by_name(name) finds one.
| Skill | Purpose | Shipped tools it allows |
|---|---|---|
author | Edit files on a change branch and commit | All nine sandbox tools |
consume | Answer questions with citations; read only | none |
summarize | Summarize a change or conversation | list_tree, read_file, git_diff, git_log |
commit-notes | Draft a commit message | git_status, git_diff, git_log |
review | Review a change and cite paths | list_tree, read_file, git_diff, git_log |
gfs-publish | Publish a reviewed change, confirming first | none (publish_docset is a host tool) |
The allowlists also name tools that a host application provides, such as search, get_page and
review_change. for_skill keeps only the tools you actually pass, so supply your own Tool
implementations under those names to enable them.
Current limitations
RigAgentRunnerdoes not use rig-core; it is Riverbeam's own tool loop over aCompletionClient.- The
remembertool spec exists, but invoking it returnsBEM-109. CallRiverBeam::memory_rememberfrom your host code instead. - Responses are not streamed token by token. The client is called with
stream: false, and frames are published after the agent finishes. RigAgentRunnerreports zero tokens and nomodel_usedin its results.- The model receives the latest user message twice; see Invoke pipeline.
WorkingTree::checkout_branchdoes not check protected refs.