Case Study: A Typed Tool Agent
This case study walks through the reference tool runner, a deterministic agent that answers tool calls. A model sends each call as JSON text. The program checks the arguments against a typed class, runs the tool, and returns a JSON result. Bad calls come back as errors that name the field that failed.
The full package is examples/agents/tool_runner. It has two files:
src/agent.auis the reusable tool layer.src/main.audefines the tools and runs the program.
The Tool Layer
"""A small typed tool layer: tracing, tool specs, and typed dispatch."""
import json
public enum ToolError:
"""Why a tool call did not produce a result."""
InvalidRequest(reason: str)
UnknownTool(name: str)
NotReady
public class ToolCall:
"""A model's request to run one tool."""
public tool: str
public args: json.Value
impl json.Codec for ToolCall
public type Fn[A, R] = Callable[def(args: A) -> R]
public type Decorator[A, R] = Callable[def(f: own Fn[A, R]) -> Fn[A, R]]
public def traced[A, R](name: own str) -> Decorator[A, R]:
"""Builds a decorator that prints a line each time the tool runs."""
return Decorator[A, R](lambda [own name] own f: trace(name.clone(), f))
def trace[A, R](name: own str, f: own Fn[A, R]) -> Fn[A, R]:
return Fn[A, R](lambda [own name, own f] args: trace_call(name, f, args))
def trace_call[A, R](name: str, f: Fn[A, R], args: A) -> R:
print(f"call {name}")
return f(args)
public class ToolSpec:
"""A tool's name and the JSON Schema of its arguments."""
public name: str
public schema: json.Value
public def tool[A: json.Codec](name: own str) -> ToolSpec:
"""Describes a tool whose arguments decode as `A`."""
return ToolSpec(name=name, schema=json.schema[A]())
public def run[A: json.Codec, R: json.Codec](
f: Fn[A, Result[R, ToolError]],
args: own json.Value,
) -> Result[json.Value, ToolError]:
"""Decodes `args`, calls `f`, and encodes its result."""
match own json.decode[A](args):
case Ok(decoded):
result = try f(decoded)
return Ok(json.encode(result))
case Err(error):
return Err(ToolError.InvalidRequest(reason=f"args {error}"))ToolCalldecodes the outer call. Itsargsfield is ajson.Value, so the arguments pass through untouched until the tool decodes them.tracedis a generic decorator factory.@traced("double")takes the tool's name, and the decorated function fixesAandR.toolpairs a name withjson.schema[A](). The schema'sdescriptionis the argument class's docstring.rundecodes the arguments asA, calls the tool, and encodes its result as JSON. It borrows the tool, so the decorated binding stays in place.
The Tools
Each tool takes one argument class and returns Result[Answer, ToolError]:
class IncrementArgs:
"""Adds a step to a number."""
value: int64
step: int64 = 1
impl json.Codec for IncrementArgs
@traced("increment")
def increment(args: IncrementArgs) -> Result[Answer, ToolError]:
return Ok(Answer(tool="increment", value=args.value + args.step))step has a default, so the schema lists only value as required, and a call may leave step out.
Dispatch
Aura has no mutable module state, so a decorator cannot register a tool. dispatch lists the tools by name and hands each call to agent.run:
def dispatch(call: own ToolCall) -> Result[json.Value, ToolError]:
if call.tool == "double":
return agent.run(double, call.args)
if call.tool == "increment":
return agent.run(increment, call.args)
return Err(ToolError.UnknownTool(name=call.tool))What It Prints
The program prints each tool's schema, then answers seven calls:
| Call | Output |
|---|---|
double with {"value": 21} | call double, then result {"tool":"double","value":42} |
increment with {"value": 42} | call increment, then the result with 43 |
increment with {"value": 42, "step": 8} | the result with 50 |
double with {"value": "x"} | rejected args $.value: expected integer, found string |
double with an extra key | rejected args $: unknown field `extra` |
| an unknown tool | unknown tool missing |
a call with no tool key | rejected $: missing field `tool` |
After the calls it retries a connection with control.retry, streams events from a child task through a Queue, and closes its session on exit. Both backends print the same output, which the CLI tests pin in stdout.txt.
Where To Go Next
- Typed Codecs covers derived codecs, decode errors, and schemas.
- Decorators covers decorator order and generic decorators.