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 three 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)
}

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:

  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.

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