What it does
The agent package provides a minimal chat-loop orchestration layer.
An Agent is configured with a name and a system prompt (description). When you call Run, it:
- Builds a session (system prompt + optional memory + user input)
- Calls the LLM
- Executes any requested tool calls
- Appends tool results and repeats until the model returns a final message
Typical workflow
Most examples follow the same flow:
- Create an
llm.LLMclient (any backend that implements the interface) - Create one or more tools with
llm.NewTool - Create an agent with a clear system prompt
- Add tools (optional) and memory (optional)
- Call
Runfor each user input
Example: minimal agent + tool
This is the essence of the Simple Agent example: define a Go function, wrap it as an LLM tool, add it to the agent, then run.
package main
import (
"context"
"fmt"
"github.com/henomis/phero/v2/agent"
"github.com/henomis/phero/v2/llm"
)
type CalculatorInput struct {
Operation string `json:"operation"`
A float64 `json:"a"`
B float64 `json:"b"`
}
type CalculatorOutput struct {
Result float64 `json:"result"`
Error string `json:"error,omitempty"`
}
func calculate(_ context.Context, input *CalculatorInput) (*CalculatorOutput, error) {
switch input.Operation {
case "add":
return &CalculatorOutput{Result: input.A + input.B}, nil
default:
return &CalculatorOutput{Error: "unknown operation"}, nil
}
}
func main() {
ctx := context.Background()
// Any llm.LLM works here (OpenAI-compatible, local, etc.)
llmClient := /* ... */
calcTool, err := llm.NewTool(
"calculator",
"Performs basic arithmetic operations",
calculate,
)
if err != nil {
panic(err)
}
a, err := agent.New(
llmClient,
"Math Assistant",
"You are a helpful math assistant. Use the calculator tool to perform calculations accurately.",
)
if err != nil {
panic(err)
}
if err := a.AddTool(calcTool); err != nil {
panic(err)
}
result, err := a.Run(ctx, llm.Text("If I have 15 apples and give away 7, then buy 23 more, how many do I have?"))
if err != nil {
panic(err)
}
fmt.Println(result.TextContent())
}
Agent handoffs
An agent can hand work to other agents at runtime. When the model decides a handoff is needed,
Run returns with Result.Handoffs listing the handoffs, in the order the model called them. Each
Handoff holds the target Agent and the Context the model passed it. The
caller then runs the targets. One entry is a plain handoff; more than one means the model fanned the work out
to several agents in the same turn.
Register handoff targets with AddHandoff. Phero automatically creates a synthetic tool
named handoff_to_<agent> whose description is the target agent's system prompt,
so the model knows when and why to route work there.
// Two specialist agents
researcher, _ := agent.New(llmClient, "researcher", "You retrieve and summarize facts.")
writer, _ := agent.New(llmClient, "writer", "You write polished prose from notes.")
orchestrator, _ := agent.New(llmClient, "orchestrator", "Decide whether to research or write.")
_ = orchestrator.AddHandoff(researcher)
_ = orchestrator.AddHandoff(writer)
result, err := orchestrator.Run(ctx, llm.Text("Write a short bio of Marie Curie."))
if err != nil {
panic(err)
}
if len(result.Handoffs) == 0 {
// No handoff: the orchestrator answered itself.
fmt.Println(result.TextContent())
return
}
// The orchestrator handed off; run every target it chose.
for _, h := range result.Handoffs {
fmt.Printf("handing off to %s: %s\n", h.Agent.Name(), h.Context)
answer, err := h.Agent.Run(ctx, llm.Text("Write a short bio of Marie Curie."))
if err != nil {
panic(err)
}
fmt.Printf("%s: %s\n", h.Agent.Name(), answer.TextContent())
}
Pass the targets the original input, h.Context, or give them shared
memory so they see the whole conversation. When a run ends in a handoff,
result.Parts holds only the text the model wrote alongside the handoff call, and is empty when it
wrote none, which is common. If the next agent needs a payload, ask the model (in the system prompt) to put it
in the handoff's context argument: that is what Handoff.Context carries. The names of
the targets are also recorded in result.Summary.HandoffAgents.
Handoffs only work where Run is called. An agent served over NATS or
A2A, or used as a sub-agent through AsTool, cannot pass them to its
caller. Those paths answer with result.ReplyText(): the model's text, or, when it wrote none, a
note such as handed off to writer: <context>, so the caller never gets an empty answer.
See "What doesn't cross the wire" in the NATS docs.
See examples/handoff for a full working example.
Run result
Agent.Run returns a *Result with three fields:
Parts []llm.ContentPart: the model's final response; callresult.TextContent()to get the plain textHandoffs []Handoff: the handoffs the model made, each with its targetAgentand theContextpassed to it; empty when there was no handoff, and more than one means fan-outSummary *trace.RunSummary: aggregated observability data for the run (token usage, latency, tool call counts, etc.)
result, err := a.Run(ctx, llm.Text(input))
if err != nil {
panic(err)
}
fmt.Println(result.TextContent())
if result.Summary != nil {
fmt.Printf("tokens used: in=%d out=%d\n",
result.Summary.Usage.InputTokens,
result.Summary.Usage.OutputTokens)
fmt.Printf("llm latency: %s\n", result.Summary.Latency.LLM)
}
Structured output
SetResponseFormat constrains the agent's final answer to a JSON schema built with
llm.NewResponseFormat (see llm). The model still calls tools freely; the format
applies to the answer it gives once it stops. Pass nil to go back to free text.
type Verdict struct {
Approved bool `json:"approved"`
Reason string `json:"reason"`
}
format, err := llm.NewResponseFormat[Verdict]("verdict", "the review decision")
if err != nil {
panic(err)
}
a.SetResponseFormat(format)
result, err := a.Run(ctx, llm.Text("Review this pull request: ..."))
if err != nil {
panic(err)
}
var v Verdict
if err := json.Unmarshal([]byte(result.TextContent()), &v); err != nil {
panic(err)
}
Streaming output
Agent.RunStream runs the agent like Run but yields progress as a sequence of
Events over a Go 1.23 iterator: text and reasoning deltas as the model produces them, a
ToolCall and ToolResult event around each tool invocation, and a final
EventDone whose Result mirrors Run. It uses the underlying LLM's
streaming API when available and otherwise falls back to a single buffered response.
for ev, err := range a.RunStream(ctx, llm.Text("Tell me a story.")) {
if err != nil {
panic(err)
}
switch ev.Type {
case agent.EventTextDelta:
fmt.Print(ev.TextDelta)
case agent.EventToolCall:
fmt.Printf("\n[tool %s]\n", ev.ToolName)
case agent.EventDone:
fmt.Println("\n", ev.Result.TextContent())
}
}
Tracing
Every run is recorded as a tree of spans: the run, each model call, each tool call, handoffs and memory access.
Attach a trace.Provider with SetTracing, or put one in the context with
trace.WithProvider so that every agent in the call tree (sub-agents included) uses it.
import (
"os"
"github.com/henomis/phero/v2/trace"
"github.com/henomis/phero/v2/trace/text"
)
a.SetTracing(trace.NewProvider(trace.WithProcessor(text.New(os.Stderr))))
Result.Summary is always populated, with or without a provider: stop reason, iterations, token usage
and cost (including sub-agents run through AsTool), per-tool, per-model and per-agent breakdowns, and
latency. See trace for processors and OpenTelemetry export.
Memory
If you attach a memory.Memory, the agent will retrieve messages before each run and will save the
conversation at the end of the call.
An empty answer (an assistant message with no text, image, reasoning or tool call, which some models return now and then) is neither saved nor replayed: sent back, it would reach OpenAI-compatible providers as an assistant message without content, which some reject, and every later run would fail. Empty answers already in a memory are dropped when it is read.
// From examples/conversational-agent (edited for brevity)
conversationMemory := memory.New(20)
a.SetMemory(conversationMemory)
// Guardrail against tool-call loops (the default is 25)
a.SetMaxIterations(10)
The conversation is saved even when the run is cancelled: the save does not inherit the cancellation of the run's context, so a turn stopped from inside a tool is still recorded.
Stopping a run
Every agent has an iteration limit of agent.DefaultMaxIterations (25 LLM calls per run). When it
is reached, Run returns ErrMaxIterationsReached together with a non-nil result
holding any text the model produced so far and the run's Summary. Change the limit with
SetMaxIterations(n); SetMaxIterations(0) removes it.
A tool stops the run on purpose, for example to wait for a human answer, by returning
agent.StopRun(cause). The run ends once every tool call of the current round has run, without
another LLM call. Run returns the partial result with its Summary (stop reason
stopped) and an error that matches both agent.ErrStopRun and the cause. A requested
stop is not a failure: the tool call is recorded as successful and the run's span ends without an error. A
sub-agent stopped inside AsTool stops its caller too.
Run also checks its context before every iteration, so cancelling it stops the run even with an
LLM that ignores the context (a stub, a cache, a local function). Cancelled runs return a non-nil partial
result with its Summary, and an error that matches both context.Canceled and the
cause given to context.WithCancelCause; a StopRun cause counts as a requested stop.
Every failed run returns a non-nil result with any partial text and its Summary, whose
StopReason tells why it ended (a guardrail block is guardrail). RunStream
yields the same error, with the partial result in the event's Result.
var errAskedHuman = errors.New("asked human")
askHuman, _ := llm.NewTool("ask_human", "Ask the user a question.",
func(_ context.Context, in *struct{ Question string }) (string, error) {
sendToUser(in.Question)
return "", agent.StopRun(errAskedHuman) // stop the run after this round
})
_ = a.AddTool(askHuman)
result, err := a.Run(ctx, llm.Text(input))
if errors.Is(err, errAskedHuman) {
// Paused: the turn is in memory, and result.Summary has the usage so far.
}
Tools
A tool is a Go function wrapped as an *llm.Tool. When the LLM requests a tool call, the agent:
- Looks up the tool by name
- Calls its handler with the JSON arguments
- Appends the tool result as a
toolrole message
Tool handlers can return any Go value; non-string results are JSON-marshaled before being added to the chat.
Common errors
ErrUndefinedLLM: creating an agent with a nil LLM clientErrNameRequired: empty agent nameErrDescriptionRequired: empty system prompt / descriptionToolAlreadyExistsError: tool name collision onAddToolorAddHandoffToolUnknownError: model requested a tool that is not registeredErrMaxIterationsReached: iteration cap hit (25 by default, often due to repeated tool calls);Runstill returns a non-nil result with any partial text and theSummaryalongside this errorErrStopRun: a tool stopped the run withStopRun; the error also wraps the tool's causecontext.Canceled/context.DeadlineExceeded: the run's context ended; the error also wraps the cancellation cause when there is one- Any other error (the LLM's, the memory's,
llm.ErrGuardrailBlocked): like all of the above,Runreturns it with a non-nil partial result and itsSummary ErrSessionSaveFailed: the run completed successfully but saving the conversation to memory failed
Run an example
Try the conversational agent example (REPL with memory). Follow the example’s README for provider setup.
# from repo root
go run ./examples/simple-agent
go run ./examples/conversational-agent
# Agent handoff pattern
go run ./examples/handoff