Agent

Turn LLMs into reliable teammates: memory in, tools out.

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:

Typical workflow

Most examples follow the same flow:

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:

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.

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)

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:

  1. Looks up the tool by name
  2. Calls its handler with the JSON arguments
  3. Appends the tool result as a tool role 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

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

Related packages