Building a Local-First Agent Framework in Rust (Part 6): Tools and the Registry
See Part 0 for the latest table of contents and sample code. New chapters will be added over time.
Chapter 6: Tools and the Registry
Chapter 5 gave abcb a way to record what happened. That was not a feature that made the model smarter, but it changed the framework's posture. The program could now leave a trail. Before adding more moving parts, we made the result inspectable.
This chapter adds the next missing piece: tools.
This post is also available on Medium. If you’re a paid Medium member and happen to read it there, it helps fund my next cup of coffee. Much appreciated ☕️😄
An agent is not only a model that answers text. It is a model connected to capabilities. Some capabilities are simple, like echoing a value or adding two numbers. Some are closer to memory, like appending a note and searching notes later. Some will eventually touch the outside world. The important part is not that the first tools are impressive. The important part is that the framework now has a place to put capabilities and a common way to call them.
One reason tools became important in LLM applications is that a model can sound confident even when it is only guessing. It may make an arithmetic mistake. It may describe a search result it never actually checked. A tool does not remove every possibility of error, but it changes the source of some answers. If the framework needs arithmetic, it can run a calculator-like tool. If it needs notes, it can search an actual notes file. The model still decides what to ask for, but the framework can make the requested operation concrete.
Who decides which tools exist?
The framework decides the available tools. It registers them, gives them names and descriptions, and wires any state or configuration they need. The model does not create new tools at runtime. It can only request a tool from the set the framework has offered. Later, the model will choose which available tool to call and what arguments to pass. The framework still decides whether that request is valid, how to execute it, and what to do with the result.
For now, the model does not choose tools yet. That comes in the next chapter, when we introduce the model's structured output envelope. This chapter is one step earlier. Before the model can say "call add_numbers with these arguments," the framework needs to know what add_numbers is, how to find it by name, and how to run it through a common interface.
The sample code for this chapter is in chapter06/abcb/.
6.1 What Is a Tool?
In abcb, a tool is a named capability with a description and an invocation method:
File: abcb/crates/abcb-core/src/lib.rs
pub trait Tool {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn invoke(&self, args: &serde_json::Value) -> Result<String, ToolError>;
}
This trait is small, but it already says a lot.
name() is the stable identifier. Later, when a model asks to call a tool, it will use the name. That means tool names are not just labels for humans. They are part of the contract between the model output and the framework.
description() is for explanation. Later, the framework can include tool descriptions in the system prompt so the model knows what tools exist and what they do. A description is not executable, but it shapes the model's decision.
A quick preview of the system prompt
A system prompt is the instruction text the framework gives the model before the user's request. Later,abcbwill use it to explain the output format and list the available tools. For example, it can say thatadd_numbersexists, describe what arguments it expects, and show the model how to ask for that tool. We are not building that prompt yet, but thedescription()method is one of the pieces it will use.
Both name() and description() return &str, not String. That means the caller only borrows the text. The tool keeps ownership of the name and description, or returns a string literal that lives for the whole program. The signature is small, but it expresses an architectural choice: asking what a tool is called should not allocate a new string or transfer ownership to the caller.
invoke() is the actual call. It receives JSON arguments and returns a string result or a ToolError.
That last signature is the boundary:
fn invoke(&self, args: &serde_json::Value) -> Result<String, ToolError>
The arguments are serde_json::Value because model tool calls are usually JSON-shaped. We do not know, at the registry boundary, whether a tool wants { "text": "hello" }, { "a": 1, "b": 2 }, or something else. Each concrete tool gets to validate its own argument shape.
The output is a String because the result will eventually go back into the model's conversation. A tool may do something structured internally, but the model sees text.
That is a simplifying choice. Later, we may want richer tool outputs. For the first registry, a string keeps the boundary easy to understand.
6.2 Tool Errors
Tools can fail in more than one way. Bad arguments are different from a failed operation, and both are different from file I/O.
File: abcb/crates/abcb-core/src/lib.rs
#[derive(Debug)]
pub enum ToolError {
InvalidArguments(serde_json::Error),
Execution(String),
Io(io::Error),
}
InvalidArguments means the JSON did not match what the tool expected. Execution is a general tool-level failure message. Io is for file-related failures, which the session note tools will need later in this chapter.
This repeats the error pattern from the previous chapter. We implement Display for the readable message, Error for standard error behavior, and From so ? can convert lower-level errors into ToolError.
File: abcb/crates/abcb-core/src/lib.rs
impl From<serde_json::Error> for ToolError {
fn from(e: serde_json::Error) -> Self {
ToolError::InvalidArguments(e)
}
}
impl From<io::Error> for ToolError {
fn from(e: io::Error) -> Self {
ToolError::Io(e)
}
}
This means a concrete tool can parse JSON or write a file with ? and still return Result<String, ToolError>.
The important distinction is where the failure belongs. A malformed argument is not a provider failure. It is not an event-log failure. It is a tool failure. Keeping those errors separate makes the framework easier to reason about once the loop has to recover from them.
6.3 A Concrete Tool: Echo
The first real tool is intentionally boring:
File: abcb/crates/abcb-tools/src/echo.rs
#[derive(Deserialize)]
struct EchoArgs {
text: String,
}
pub struct Echo;
impl Tool for Echo {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echoes the `text` field of its arguments back as the output."
}
fn invoke(&self, args: &serde_json::Value) -> Result<String, ToolError> {
let typed: EchoArgs = serde_json::from_value(args.clone())?;
Ok(typed.text)
}
}
#[derive(Deserialize)] asks serde to generate code that can build EchoArgs from JSON-shaped data. We used serde derives in earlier chapters for messages, sessions, and events. There, Serialize mattered because we wanted Rust values to become JSON too. Here, EchoArgs only needs Deserialize because the tool only reads arguments from JSON. It does not need to write EchoArgs back out.
The model-facing argument is a JSON value, but the tool converts it into a typed Rust struct before using it:
let typed: EchoArgs = serde_json::from_value(args.clone())?;
EchoArgs has one field:
text: String
If the JSON has a text field with a string, the conversion succeeds. If the field is missing, or if text has the wrong type, serde returns an error. Because ToolError implements From<serde_json::Error>, the ? operator converts that serde error into ToolError::InvalidArguments.
There is one small cost here: from_value takes ownership of the JSON value, but invoke receives &serde_json::Value. We call args.clone() so serde can consume an owned value while the caller keeps its original argument value.
For this first implementation, that trade-off is fine. It keeps the trait simple and lets each tool choose its own typed argument struct.
6.3.1 Move, Copy, and Clone
The args.clone() call is a good place to slow down and separate three Rust ideas: move, copy, and clone.
By default, Rust uses move semantics for values that own resources:
let a = String::from("hi");
let b = a; // move
println!("{a}"); // error: a was moved
String owns heap data. When we assign a to b, ownership moves to b, and a becomes unusable. Rust does this to prevent two owners from freeing or mutating the same allocation as if they both uniquely owned it.
Some types opt into the Copy trait. For those types, the same assignment syntax copies the value instead of moving it:
let a: i32 = 5;
let b = a; // copy
println!("{a}"); // ok
The syntax is the same: let b = a. The type decides whether that operation is a move or a copy.
Copy is only allowed for types where a simple bit-for-bit duplicate is semantically correct. Common examples are integers, floats, bool, char, shared references like &T, raw pointers, and tuples or arrays whose elements are all Copy. Types such as String, Vec<T>, HashMap<K, V>, Box<T>, and Rc<T> are not Copy because they own or manage resources. &mut T is also not Copy, because copying it would violate Rust's rule that there can be only one active mutable reference.
Clone is different. Clone is explicit:
let s1 = String::from("hi");
let s2 = s1.clone(); // explicit clone, both valid
let s3 = s1; // move, s1 invalid after this
String::clone() allocates a new heap buffer and copies the text. Other types may clone differently. For example, cloning an Rc<T> increments a reference count. The important part is that .clone() is visible at the call site because it may do real work.
So the short rule is:
Copy: implicit, cheap, bit-for-bit duplicationClone: explicit, may allocate or do other work- move: default ownership transfer for non-
Copyvalues
Back in Echo, serde_json::Value is not Copy. from_value needs an owned Value, and invoke only has a borrowed &serde_json::Value. Calling args.clone() creates an owned JSON value for serde to consume. That is why the clone is written explicitly.
6.4 Another Tool: Adding Numbers
AddNumbers uses the same pattern with a different argument type:
File: abcb/crates/abcb-tools/src/add_numbers.rs
#[derive(Deserialize)]
struct AddArgs {
a: f64,
b: f64,
}
pub struct AddNumbers;
impl Tool for AddNumbers {
fn name(&self) -> &str {
"add_numbers"
}
fn description(&self) -> &str {
"Adds two numbers `a` and `b` and returns their sum."
}
fn invoke(&self, args: &serde_json::Value) -> Result<String, ToolError> {
let typed: AddArgs = serde_json::from_value(args.clone())?;
let sum = typed.a + typed.b;
Ok(format!("{sum}"))
}
}
This is not a math library. It is a small test of the tool boundary.
The input is JSON. The tool validates that JSON into a Rust type. The numeric result is then converted to text with format!("{sum}") and returned through Ok(...):
Ok(format!("{sum}"))
That Ok is the successful side of Result<String, ToolError>. The same trait can support both Echo and AddNumbers, even though their argument structs are different and one tool returns the parsed text field while the other returns a computed value.
That is the shape we need before the model can ask for a tool. The framework does not need to know every argument schema at the registry boundary. It needs to know the tool name, description, and how to invoke it with JSON.
6.5 The Registry
A tool by itself is only a capability. The framework also needs a way to find a tool by name.
That is the job of ToolRegistry:
File: abcb/crates/abcb-core/src/lib.rs
pub struct ToolRegistry {
tools: HashMap<String, Box<dyn Tool>>,
}
The registry is a map from tool name to tool implementation. In the next chapter, when the model says something like "call echo with these arguments," the framework will look up echo in this registry.
The implementation starts plainly:
File: abcb/crates/abcb-core/src/lib.rs
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
pub fn register(&mut self, tool: impl Tool + 'static) -> Result<(), RegistryError> {
let name = tool.name().to_string();
if self.tools.contains_key(&name) {
return Err(RegistryError::DuplicateName(name));
}
self.tools.insert(name, Box::new(tool));
Ok(())
}
}
Registration is loud about duplicates. If a name is already present, the registry returns an error instead of silently replacing the old tool.
That is a small policy choice, but it matters. Tool names are part of the model contract. Accidentally overwriting one tool with another could make the prompt say one thing while the runtime does another. It is better for the program to complain.
6.5.1 A Short Map of Rust Collections
HashMap is the new collection in this chapter, but it helps to place it next to the collections we have already used.
Vec<T> is Rust's growable array. We used it for Session.messages and for collecting events from the log. It is the first collection to reach for when order matters and lookup by position is good enough:
let mut v = vec![1, 2, 3];
v.push(4);
let first = v.first(); // Option<&i32>
let last = v.last(); // Option<&i32>
Indexing with v[0] is direct, but it panics if the index is out of bounds. Methods like first(), last(), and get(index) return Option, which lets the caller handle the missing case.
VecDeque<T> is a double-ended queue. Chapter 4 used it inside MockProvider because the mock provider needed to take scripted responses from the front:
let mut q = VecDeque::from(["first", "second"]);
let next = q.pop_front(); // Option<&str>
Use VecDeque when pushing or popping from the front is part of the normal workflow.
HashMap<K, V> is a hash table. It stores values by key, which is exactly what the tool registry needs:
let mut tools: HashMap<String, Box<dyn Tool>> = HashMap::new();
tools.insert("echo".to_string(), Box::new(Echo));
let tool = tools.get("echo");
In a HashMap, keys must support equality and hashing. String works well for tool names because the registry needs owned names as keys. get returns Option<&V> because the key may not exist.
One detail is easy to forget: HashMap iteration order is not stable. If a test or prompt needs deterministic order, collect the keys and sort them, or use an ordered collection such as BTreeMap.
Rust has a few other standard collections worth knowing:
HashSet<T>stores unique values, useful for "have I seen this already?"BTreeMap<K, V>andBTreeSet<T>keep keys sorted and support range queries.BinaryHeap<T>is a priority queue. By default, it is a max-heap.
We do not need all of them in abcb yet. For this chapter, the important choice is simple: the registry needs fast name-based lookup, so HashMap<String, Box<dyn Tool>> is the right shape.
6.6 Why Box<dyn Tool>?
Chapter 4 used a generic parameter for providers:
pub fn one_turn(
provider: &mut impl Provider,
user_message: impl Into<String>,
) -> Result<Message, ProviderError>
That worked because one_turn only needed one provider value, and the concrete provider type was known at the call site.
The registry has a different problem. It needs to store many tools in one collection. Those tools may have different concrete types, for example:
EchoAddNumbersSessionNoteAppendSessionNoteSearch
A HashMap<String, Echo> can store only Echo. A HashMap<String, AddNumbers> can store only AddNumbers. The registry needs one map that can hold any type that implements Tool.
That is why the field is:
HashMap<String, Box<dyn Tool>>
dyn Tool means a trait object: some concrete type that implements Tool, chosen at runtime. Box puts that value behind an owned pointer so the map has a single known size to store.
The rule of thumb is useful:
Use generics when the concrete type is known at the call site. Use dyn when the set of concrete types is open and you need to collect them behind one interface.
The registry is exactly that second case.
6.6.1 impl Trait vs. dyn Trait
Both impl Trait and dyn Trait let us talk about a value that implements a trait without writing the concrete type in the signature. The difference is when Rust knows the concrete type.
impl Trait is resolved at compile time:
fn one_turn(provider: &mut impl Provider, user_message: impl Into<String>)
In argument position, impl Provider is shorthand for a generic parameter. Each call still has one concrete provider type. Rust can compile code for that concrete type directly.
In return position, impl Trait has a slightly different meaning:
fn names(&self) -> impl Iterator<Item = &str>
This says, "I return one specific iterator type, but I do not want to write its full concrete name." The caller knows it gets something that implements Iterator, but the function still returns one concrete type.
dyn Trait is different. It is runtime polymorphism through a trait object:
fn run(tool: &dyn Tool)
At runtime, tool may point at an Echo, an AddNumbers, or another concrete tool. Rust calls the right implementation through a small runtime table called a vtable. Because the concrete type is not known from the signature alone, dyn Trait is used behind a pointer, such as &dyn Tool or Box<dyn Tool>.
The rule of thumb is:
- Use
impl Traitor generics when each call site has one concrete type. - Use
dyn Traitwhen you need different concrete types in the same place, such as one registry that stores many tool kinds.
6.7 Why register Needs 'static
The register method has one bound that can look mysterious:
pub fn register(&mut self, tool: impl Tool + 'static) -> Result<(), RegistryError>
The Tool part says the argument must implement the Tool trait. The 'static part is a lifetime bound. This is easy to confuse with a value that has the 'static lifetime, such as a string literal:
let label: &'static str = "echo";
That reference points at data that is available for the whole program. The bound in impl Tool + 'static is different. It does not mean this particular tool value must live forever. It means the type we put into the registry can be kept without depending on short-lived borrowed references.
The body of register takes ownership of the tool argument. Then it puts that tool into a Box and stores the box in the map:
self.tools.insert(name, Box::new(tool));
Box::new(tool) moves the tool value onto the heap and gives the registry an owned pointer to it. After that insertion, the caller no longer owns the tool. The registry does.
That is why the lifetime bound matters. After registration, the tool can live inside the registry for as long as the registry lives. If that tool secretly borrowed a short-lived value from the caller, the registry could outlive the borrowed value. Rust refuses that possibility.
This does not mean every tool must live forever. It means the stored tool must not depend on non-static borrowed data. A tool can still own state, such as a PathBuf, a counter, or a configuration value. Owning state is fine. Borrowing some temporary state from the caller is what the bound prevents.
That is a good default for a registry. The wiring layer constructs tools, gives them the state they need, and then hands ownership to the registry.
6.8 Looking Up a Tool
The registry lookup returns an Option:
File: abcb/crates/abcb-core/src/lib.rs
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
self.tools.get(name).map(|boxed| &**boxed)
}
If a tool with that name exists, the result is Some(&dyn Tool). If not, the result is None.
This is deliberately not a RegistryError. Looking up a missing tool may mean different things in different places. In a test, it might be a direct failure. In the agent loop, it may become a message back to the model: "that tool is not available." The registry does not choose that policy. It only reports whether the name exists.
The map(|boxed| &**boxed) part is doing a small conversion. HashMap::get returns an Option<&Box<dyn Tool>>. The caller does not need to know there is a Box inside the registry. The caller only needs a borrowed tool.
The expression &**boxed is compact, so it is worth unpacking:
boxed // &Box<dyn Tool>
*boxed // look through the shared reference to the Box
**boxed // look through the Box to the dyn Tool
&**boxed // &dyn Tool
The map call is what changes the outer Option shape. The expression before map is self.tools.get(name), and its type is:
Option<&Box<dyn Tool>>
The closure runs only in the Some case. It receives the &Box<dyn Tool> and returns &dyn Tool. After map, the whole result becomes:
Option<&dyn Tool>
We are not moving the boxed tool out of the registry. We are taking the borrowed box, looking through it to the tool inside, and then reborrowing that tool as &dyn Tool. That reborrow is the important part. It gives the caller temporary access to the tool while the registry keeps ownership.
Rust often hides some of this with auto-deref at method-call sites. Here we write the conversion explicitly because the return type says exactly what we want to expose.
6.8.1 Reborrowing
Reborrowing means taking a new, temporary borrow through an existing borrow. In get, we already have a borrowed box from the map. We look through that borrow and create a new borrowed view of the tool inside:
&**boxed
The registry still owns the box. The caller only receives a temporary borrowed view.
The same idea shows up often with mutable references. Suppose a function needs &mut HashMap<i32, i32>:
fn bump(map: &mut HashMap<i32, i32>) {
*map.entry(1).or_insert(0) += 1;
}
If we already have a mutable reference, this call still works:
let mut map: HashMap<i32, i32> = HashMap::new();
let r: &mut HashMap<i32, i32> = &mut map;
bump(r); // looks like a move, but the compiler inserts &mut *r
bump(r); // still works! r was reborrowed, not moved
At first glance, bump(r) may look as if it moves r into the function. In practice, the compiler inserts a reborrow at the call site, roughly like this:
bump(&mut *r);
The original r is temporarily inactive during the call, then usable again afterward. That is different from assigning the mutable reference to another binding:
let r2: &mut HashMap<i32, i32> = r;
That assignment moves the mutable reference itself, so r can no longer be used. Reborrowing is how Rust lets us lend through an existing reference without giving the original reference away.
6.9 Listing Names
The registry can also list the names it knows:
File: abcb/crates/abcb-core/src/lib.rs
pub fn names(&self) -> impl Iterator<Item = &str> {
self.tools.keys().map(String::as_str)
}
This is the first time the registry gives us a read-only view over its contents. It does not return a Vec<String>. It returns an iterator of borrowed string slices.
There is also a small closure here:
map(String::as_str)
This is equivalent to writing:
map(|name| name.as_str())
The |name| ... syntax is a closure: a small function-like value written inline. In this case, the closure receives a &String from the map keys and returns &str.
We do not need a deep closure theory yet. The practical idea is enough for now: iterator adapters often take small bits of behavior, and closures are how we provide that behavior inline.
6.10 The Tools Crate
Until now, most of the interesting framework code has lived in abcb-core, with the CLI as the user-facing edge. This chapter adds a new workspace member:
File: abcb/Cargo.toml
[workspace]
members = ["crates/abcb-cli", "crates/abcb-core", "crates/abcb-tools"]
resolver = "3"
The new crate is abcb-tools:
File: abcb/crates/abcb-tools/Cargo.toml
[package]
name = "abcb-tools"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
abcb-core = { path = "../abcb-core" }
serde = { workspace = true }
serde_json = { workspace = true }
This split was deferred earlier. That was intentional. A crate split has a cost: another package, another dependency edge, another place to look. In the first chapters, there was not enough tool code to justify that cost.
Now there is. abcb-core defines what a tool is. abcb-tools provides concrete tool implementations.
File: abcb/crates/abcb-tools/src/lib.rs
mod add_numbers;
mod echo;
mod session_notes;
pub use add_numbers::AddNumbers;
pub use echo::Echo;
pub use session_notes::{SessionNoteAppend, SessionNoteSearch};
This keeps the direction of dependency clean. Tools depend on the core trait. Core does not depend on the concrete tools. That lets the framework stay generic while the tool crate grows around it.
6.10.1 What pub Means
Rust items are private by default. A function, struct, enum, trait, or module can be used by the module where it is defined and by child modules, but sibling modules and outside crates cannot use it unless we make it visible.
pub makes an item public to outside code that can reach its module path:
pub struct Echo;
pub trait Tool { /* ... */ }
pub fn new(path: PathBuf) -> Self { /* ... */ }
There is still a path involved. If Echo lives inside the private echo module, outside code cannot simply reach abcb_tools::echo::Echo unless the module is public too. In this crate, we keep the internal modules private:
mod echo;
Then we re-export selected types from the crate root:
pub use echo::Echo;
That means callers can write abcb_tools::Echo without knowing that the implementation lives in echo.rs. This is a useful pattern: keep the file structure private, and expose the public API from lib.rs.
Rust also has narrower visibility forms, such as pub(crate), which means "public inside this crate, private to other crates":
pub(crate) fn normalize_tool_name(name: &str) -> String {
name.trim().to_string()
}
We do not need that here yet, but it is useful when a helper should be shared internally without becoming part of the public crate API.
6.11 Tools Can Own State
Echo and AddNumbers are stateless. The session note tools are different. They need to know where the notes file lives.
File: abcb/crates/abcb-tools/src/session_notes.rs
pub struct SessionNoteAppend {
path: PathBuf,
}
impl SessionNoteAppend {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
}
There are two different kinds of input around this tool.
The first kind is framework configuration. In this case, the tool needs a file path. That path answers the question, "where should session notes be stored?" The model should not decide that. The framework, or the CLI wiring code, should decide it when it creates the tool:
let tool = SessionNoteAppend::new(path);
After construction, the tool owns the PathBuf in its path field. The model does not see that path, and it does not get to change it through JSON arguments.
The second kind of input is the model-facing argument. That is the JSON value passed to invoke. For SessionNoteAppend, the model-facing part is only the note text:
File: abcb/crates/abcb-tools/src/session_notes.rs
#[derive(Deserialize)]
struct AppendArgs {
note: String,
}
impl Tool for SessionNoteAppend {
fn name(&self) -> &str {
"session_note_append"
}
fn description(&self) -> &str {
"Appends `note` as a new line in the session notes file."
}
fn invoke(&self, args: &serde_json::Value) -> Result<String, ToolError> {
let typed: AppendArgs = serde_json::from_value(args.clone())?;
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(&self.path)?;
writeln!(file, "{}", typed.note)?;
Ok("note appended".into())
}
}
This is an important boundary. The model can provide the note text. It cannot provide the file path. The tool owns the path because the framework gave it that path at construction time.
That pattern will matter later for safer tools. A file-reading tool, a command-running tool, or a Godot bridge tool should not let the model smuggle policy through JSON arguments. The wiring layer owns policy and configuration. The model supplies only the arguments the tool is designed to accept.
6.12 What We Chose
This chapter makes several design choices that will become visible once the agent loop starts calling tools.
First, a tool has a name, description, and invoke method. The name is for lookup. The description is for explanation. The invocation is for execution.
Second, tool arguments enter as serde_json::Value. Each tool validates that open JSON value into its own typed argument struct.
Third, tool output is a String. That keeps the first tool boundary compatible with model-visible text.
Fourth, the registry stores Box<dyn Tool>. That lets one collection hold many concrete tool types.
Fifth, tools own their configuration through constructors. SessionNoteAppend::new(path) is a small example, but the policy is larger: the wiring layer owns where state lives.
Sixth, missing tools return Option, while duplicate registration returns an error. Lookup leaves policy to the caller. Duplicate registration is loud because duplicate names would make the tool contract ambiguous.
These are not final laws. They are dated choices for the framework at this stage. The point is that the choices are visible in the types.
6.13 What We Have So Far
At the end of this chapter, abcb has a tool interface, a registry, and a first concrete tool crate.
The core crate now has Tool, ToolError, ToolRegistry, and RegistryError. The new abcb-tools crate has Echo, AddNumbers, SessionNoteAppend, and SessionNoteSearch.
The model still cannot call these tools. That is the next step. We need a structured language for the model to say, "call this tool with these arguments," or "I am done, here is the final answer."
That language is the envelope. We named it in Chapter 5. In the next chapter, we finally give it a shape.
To be continued