Skip to content
Radiant LabsRadiant Labs

Provided plugins

Tool calling

The ToolsPlugin provides a registry for defining and executing tools that LLMs can invoke.

ToolsPlugin provides a ToolRegistry global resource that stores tool definitions and dispatches execution. Tools are async functions annotated with #[tool], which generates the Tool trait implementation and a JSON schema automatically. The LLM sees these schemas as available functions it can call, and your systems execute the calls and return results.

use polaris_ai::system::server::Server;
use polaris_ai::tools::{tool, ToolError, ToolRegistry, ToolsPlugin};
#[tool]
/// Search for documents matching a query.
async fn search(
/// The search query string.
query: String,
/// Maximum number of results to return.
#[default(10)]
limit: usize,
) -> Result<String, ToolError> {
Ok(format!("Found results for: {query} (limit {limit})"))
}

The #[tool] macro turns this function into a struct that implements the Tool trait. The function’s doc comment becomes the tool’s description. Each parameter’s doc comment becomes that parameter’s description in the JSON schema. The macro generates the schema from the parameter types — String, usize, bool, and other standard types are supported out of the box.

Mark a parameter as optional with the #[default(value)] attribute. When the LLM omits that argument, the default value is used instead.

use polaris_ai::polaris_tools;
use polaris_ai::tools::{tool, ToolError};
#[tool]
/// Fetch a web page.
async fn fetch_url(
/// The URL to fetch.
url: String,
/// Request timeout in seconds.
#[default(30)]
timeout_secs: u64,
/// Whether to follow redirects.
#[default(true)]
follow_redirects: bool,
) -> Result<String, ToolError> {
Ok(format!("Fetched {url}"))
}

In the generated JSON schema, timeout_secs and follow_redirects are not listed as required properties. The LLM can include them or leave them out.

ToolRegistry is the central store for tools. It is created by ToolsPlugin during the server’s build phase and promoted to a GlobalResource during ready. Systems access it through Res<ToolRegistry>.

The registry provides four main operations:

  • register adds a single tool. The tool’s name must be unique; a duplicate panics.
  • register_toolset adds all tools from a Toolset implementation at once.
  • definitions returns Vec<ToolDefinition> for all registered tools, which you pass to the LLM request.
  • execute runs a tool by name with JSON arguments and returns a JSON result.
use polaris_ai::tools::ToolRegistry;
fn configure_registry(registry: &mut ToolRegistry) {
registry.register(search());
registry.register(fetch_url());
}

Calling search() returns the macro-generated struct instance. You pass it directly to register.

Tools belong in plugins. Your plugin declares a dependency on ToolsPlugin, then registers tools during build.

use polaris_ai::system::plugin::{Plugin, PluginId, Version};
use polaris_ai::system::server::Server;
use polaris_ai::tools::{tool, ToolError, ToolRegistry, ToolsPlugin};
#[tool]
/// Search for documents matching a query.
async fn search(
/// The search query string.
query: String,
/// Maximum number of results to return.
#[default(10)]
limit: usize,
) -> Result<String, ToolError> {
Ok(format!("Found results for: {query}"))
}
struct SearchPlugin;
impl Plugin for SearchPlugin {
const ID: &'static str = "myapp::search";
const VERSION: Version = Version::new(0, 1, 0);
fn dependencies(&self) -> Vec<PluginId> {
vec![PluginId::of::<ToolsPlugin>()]
}
fn build(&self, server: &mut Server) {
let mut registry = server
.get_resource_mut::<ToolRegistry>()
.expect("ToolsPlugin must be added before SearchPlugin");
registry.register(search());
}
}

The dependency on ToolsPlugin ensures the ToolRegistry exists by the time build runs.

When you have several related tools, group them into a Toolset using the #[toolset] macro on an impl block. Each method marked with #[tool] becomes an individual tool, and the struct’s fields are available as shared state.

use polaris_ai::polaris_tools;
use polaris_ai::tools::{tool, toolset, ToolError};
use std::path::PathBuf;
#[derive(Clone)]
struct FileTools {
working_dir: PathBuf,
}
#[toolset]
impl FileTools {
#[tool]
/// List files in a directory.
async fn list_files(
&self,
/// Directory path relative to the working directory.
path: String,
) -> Result<String, ToolError> {
let resolved = self.working_dir.join(&path);
let mut entries = tokio::fs::read_dir(&resolved)
.await
.map_err(|e| ToolError::execution_error(e.to_string()))?;
let mut files = Vec::new();
while let Some(entry) = entries.next_entry().await.transpose() {
if let Ok(entry) = entry {
files.push(entry.file_name().to_string_lossy().to_string());
}
}
Ok(files.join("\n"))
}
#[tool]
/// Read the contents of a file.
async fn read_file(
&self,
/// File path relative to the working directory.
path: String,
) -> Result<String, ToolError> {
let resolved = self.working_dir.join(&path);
tokio::fs::read_to_string(&resolved)
.await
.map_err(|e| ToolError::execution_error(e.to_string()))
}
}

Register a toolset with register_toolset:

use polaris_ai::tools::ToolRegistry;
use std::path::PathBuf;
fn register_file_tools(registry: &mut ToolRegistry) {
registry.register_toolset(FileTools {
working_dir: PathBuf::from("/tmp/sandbox"),
});
}

The Toolset trait has one method — fn tools(self) -> Vec<Box<dyn Tool>> — which the macro generates. The generated tools share the toolset instance through an Arc.

To make tools available to the LLM, use the with_registry extension method on LlmRequestBuilder. This adds all registered tool definitions to the request so the model knows which functions it can call.

use polaris_ai::tools::LlmRequestBuilderExt;
use polaris_ai::models::llm::Llm;
use polaris_ai::tools::ToolRegistry;
async fn call_with_tools(llm: &Llm, registry: &ToolRegistry) {
let response = llm
.builder()
.with_registry(registry)
.system("You are a helpful assistant with access to tools.")
.user("Find documents about Rust concurrency.")
.generate()
.await
.expect("LLM generation failed");
}

You can also add tools individually with with_tool or from a toolset with with_toolset, but with_registry is the typical approach when you have a ToolRegistry available as a resource.

When the LLM response contains tool calls, you execute them through the registry and feed the results back as messages for the next turn.

use polaris_ai::models::llm::{Llm, Message, ToolResultContent};
use polaris_ai::tools::ToolRegistry;
use polaris_ai::tools::LlmRequestBuilderExt;
async fn execute_tool_calls(
llm: &Llm,
registry: &ToolRegistry,
messages: &mut Vec<Message>,
) {
let response = llm
.builder()
.with_registry(registry)
.system("You are a helpful assistant.")
.messages(messages.clone())
.generate()
.await
.expect("LLM generation failed");
if response.has_tool_calls() {
// Add the assistant's response (which contains the tool calls) to history
messages.push(Message::assistant_from_blocks(response.content.clone()));
for call in response.tool_calls() {
let result = registry
.execute(&call.function.name, &call.function.arguments)
.await;
let content = match result {
Ok(value) => ToolResultContent::Text(value.to_string()),
Err(err) => ToolResultContent::Text(format!("Error: {err}")),
};
messages.push(Message::tool_result(&call.id, content));
}
}
}

Each ToolCall in the response has an id (which you pass back in the tool result so the LLM can match results to calls) and a function field containing the name and arguments. The registry.execute method looks up the tool by name and runs it with the provided JSON arguments.

This execute-and-append pattern is the core of a tool-use loop. In a full agent, you would wrap this in a graph loop that continues calling the LLM until it responds without tool calls. See the Building a ReAct agent guide for a complete example.

Use ToolRegistry::execute_with(name, arguments, &context) when a tool declares #[context] parameters. execute creates an empty ToolContext, so it cannot supply workspace, identity or permission state expected by such tools. Check the tool’s context requirements before invoking it.

The file example above demonstrates a toolset, not a filesystem sandbox: joining a path alone does not restrict traversal or symlinks. Use the workspace’s permission-aware shell/filesystem tools or enforce a boundary in your application before exposing file access to a model.

The LlmReasonExt trait adds a reason method to LlmRequestBuilder as a convenience for single-shot tool-use calls. It behaves like generate but validates that at least one tool definition is present before making the call.

use polaris_ai::tools::{LlmRequestBuilderExt, LlmReasonExt};
use polaris_ai::models::llm::Llm;
use polaris_ai::tools::ToolRegistry;
async fn single_shot(llm: &Llm, registry: &ToolRegistry) {
let response = llm
.builder()
.with_registry(registry)
.user("What files are in the current directory?")
.reason()
.await
.expect("reason call failed");
if response.has_tool_calls() {
// Handle tool calls
} else {
println!("{}", response.text());
}
}

reason returns ReasonError::NoTools if you call it without adding any tool definitions, and ReasonError::Generation if the underlying LLM call fails.