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 four 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.)FinishReason llm.FinishReason: why the last LLM call stopped; empty when the provider reports no reason
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)
}
Truncated answers
A model that hits its output limit stops mid-answer, and the provider reports llm.FinishLength.
- With tool calls: the agent does not run them, because their arguments are probably cut
too (a large
write, for example). Each call gets an error result that asks the model to retry with smaller arguments, and the loop goes on. Streaming still emitsEventToolCallandEventToolResultfor them. - Without tool calls: the cut answer is returned as final, with
Result.FinishReason == llm.FinishLength. The app decides what to do, for example run again with "continue".
result, err := a.Run(ctx, llm.Text(input))
if err != nil {
return err
}
if result.FinishReason == llm.FinishLength {
// The answer was cut off: ask the model to go on. This needs memory
// (SetMemory), so the next run sees the cut answer.
result, err = a.Run(ctx, llm.Text("Continue exactly where you stopped."))
}
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, an
EventUsage with the token usage of each LLM call, an EventCompactionStart and
EventCompactionEnd around each compaction (see below), 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.
The tool calls of one round run concurrently. Their ToolCall events come in call order before
they start, and each ToolResult comes as soon as its call finishes, so a slow tool does not hold
back the results of fast ones. Match a result to its call with ToolCallID.
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.
By default the turn is saved once, when the run ends. For long runs, a.SetCheckpoint(true) saves
the new messages after every iteration that ran tool calls, so a process that crashes loses at most the
iteration in progress. An assistant message is always saved with the results of its tool calls, and
Run with no input continues from what memory holds.
Changing the prompt or the model
To switch models between turns, call SetLLM: the agent keeps its tools, handoffs, memory and
settings, and the next run continues the same conversation with the new model. Call it between runs, not
during one.
SetLLM changes only the agent's own client. A SummaryCompactor keeps the client it
was built with, so set the compaction again to write summaries with the new model too.
if err := a.SetLLM(otherLLM); err != nil {
return err
}
a.SetCompaction(agent.NewSummaryCompactor(otherLLM), threshold) // as you set it before
The system prompt is set when the agent is built. To change it, build a new agent and give it the same memory: the next run loads the session from memory, so the conversation goes on.
next, err := agent.New(otherLLM, "coder", newPrompt)
if err != nil {
return err
}
for _, t := range tools {
_ = next.AddTool(t)
}
next.SetMemory(mem) // the same memory as the old agent
a = next
Compaction: long runs
Every tool call and result stays in the session, so a run with many iterations, such as a coding agent
reading files and running tests, eventually outgrows the model's context window. SetCompaction
shortens the session during the run:
// Summarize with a cheaper model; keep the last 20 messages whole.
a.SetCompaction(agent.NewSummaryCompactor(cheapLLM, agent.WithSummaryKeepRecent(20)), 150_000)
- Before an LLM call, once the previous call's prompt and output tokens reached the threshold (here
150k), the session is handed to the compactor. Pass 0 to skip this check. Before the provider has reported
a size (the first call of a run, after a compaction, or with no usage at all), the session is estimated with
llm.EstimateTokens(about four characters per token), so a long history loaded from memory is compacted before it is sent. - When a call fails with
llm.ErrContextLengthExceeded, the session is compacted and the call made again, once. A second such error in a row ends the run. - The session becomes the system prompt, the summary as a user message
(
memory.SummaryMessages: a user message keeps its place, where Anthropic and Gemini would move a system message into the system prompt), the pinned task, and the kept messages. A later compaction updates that summary instead of summarizing it again. NewSummaryCompactorwrites the summary from a prompt that lists tool calls and results, in fixed, domain-neutral sections (task, decisions and facts, work done, preferences, open items).WithSummaryPrompt(instructions)replaces those instructions, for example with sections that fit a coding agent or a support chat; the previous summary and the conversation are added after them. It keeps at mostWithSummaryKeepRecentmessages, fewer when they pass a token budget (a quarter of the threshold, orWithSummaryKeepTokens), and cuts kept tool results longer than 8,000 characters. It never separates a tool call from its results, and it pins the latest user request word for word, so the task is not lost.WithSummaryMaxTokens(n)bounds the summary, for small context windows: the prompt asks for at mostntokens and the call's output limit is1.5 × n. A summary cut at that limit is dropped and asked for again without it.- Any
agent.Compactor(oragent.CompactorFunc) can replace it. It gets anagent.CompactInput(the previous snapshot, the messages after it, the token budget) and returns anagent.Compaction: amemory.Snapshotand the last input messages it keeps, possibly shortened. Kept messages that are not the input's last ones, or that start with a tool result, fail withagent.ErrInvalidCompaction;nilmeans nothing to compact. - Memory keeps every message once: before compacting the run saves the messages it has not saved yet, and
after it only the messages added later. With a memory that keeps snapshots
(
memory.SnapshotStore, such asmemory/simple), the summary is stored as a snapshot and the next run starts from it: the summary, then only the messages after it. If another writer stored a snapshot first, or the log no longer holds the compacted messages, the summary lives in the run only, as with any other memory, and the run goes on. Each compaction is traced as a memory span with opcompact; a failure returns an*agent.CompactionError. RunStreamemitsEventCompactionStartandEventCompactionEndaround each compaction, so a UI can say "compacting…" while the summary is written. TheirCompaction(*agent.CompactionInfo) holds the trigger (CompactThresholdorCompactContextLength), the session's estimated tokens and message count before and, at the end, after, whether it was compacted, and the error it failed with.a.Compact(ctx)compacts the conversation in memory now, between runs, for a/compactcommand. It loads the session, runs the compactor and stores the snapshot, so the next run starts from the summary; it returns theCompactionInfo(triggerCompactManual). It needs a compactor (agent.ErrNoCompactor) and a memory that keeps snapshots (memory.ErrSnapshotsUnsupported): outside a run, a summary that is not stored would be lost.
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. 25 suits short tool-using
tasks. An agent that works over many turns, such as a coding agent that reads, edits and runs tests,
should raise it (200 or more), or remove it and bound the run with a context deadline.
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.
When the model calls several tools at once, the calls run concurrently. a.SetMaxParallelTools(n)
caps how many run at the same time. A tool marked with tool.SetExclusive(true) runs alone, in call
order: after the calls before it and before the calls after it. The built-in edit and
write tools are exclusive, so two edits of the same file in one round apply in order. A panic in a
tool is recovered and reported to the model as a failed call. A handler can read its call (ID, name,
arguments) with llm.ToolCallFromContext(ctx).
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