Files
majordomo/agent/agent.go
T
steve 0147a79d18
CI / Tidy (push) Successful in 9m31s
CI / Build & Test (push) Successful in 10m13s
feat: conversion-driven extensions — resolvers, DefineTool, hooks, ops controls
Phase 9a (ADR-0014): Registry.RegisterResolver for dynamic tiers;
DefineTool[Args] typed tools; Usage cache/reasoning detail fields wired
through anthropic/openai/google; WithPromptCaching (Anthropic
cache_control); agent supervision hooks (WithMaxStepsFunc, WithSteer,
WithCompactor, WithToolErrorLimits + ErrToolLoop); health
Bench/Unbench/Snapshot; ChainConfig.Observer failover events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:30:06 +02:00

399 lines
13 KiB
Go

// Package agent runs LLM-backed agents: a Model, a system prompt, and one
// or more toolboxes, executed as a tool-dispatch loop until the model
// produces a final answer (or MaxSteps intervenes).
//
// The loop never panics: tool handlers run through the panic-recovering
// executor in llm, unknown tools come back as error results the model can
// react to, and step observers receive every intermediate step. Skills
// (package skill) attach additively: their instructions extend the system
// prompt and their tools extend the merged toolset.
package agent
import (
"context"
"errors"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// DefaultMaxSteps bounds the tool-dispatch loop when WithMaxSteps is not
// given.
const DefaultMaxSteps = 10
// ErrMaxSteps reports that the loop hit its step budget before the model
// produced a final answer. Run returns it alongside a non-nil *Result
// carrying the transcript so far.
var ErrMaxSteps = errors.New("agent: max steps reached without a final answer")
// ErrToolLoop reports that the loop tripped a tool-error guard
// (consecutive all-error steps or identical repeated calls; see
// WithToolErrorLimits). Run returns it alongside the partial *Result.
var ErrToolLoop = errors.New("agent: tool-error guard tripped")
// Skill is the contract skills satisfy (defined here so agent does not
// depend on the skill package; package skill provides implementations).
// Instructions are appended to the agent's system prompt; Tools (optional,
// may be nil) extend the agent's toolset.
type Skill interface {
Name() string
Instructions() string
Tools() *llm.Toolbox
}
// Step is one completed iteration of the loop: the model's response and,
// when it requested tools, the results that were fed back.
type Step struct {
// Index is the 0-based step number.
Index int
// Response is the model output for this step.
Response *llm.Response
// Results are the executed tool outcomes (empty on the final step).
Results []llm.ToolResult
}
// Result is the outcome of a Run.
type Result struct {
// Output is the final assistant text.
Output string
// Messages is the full transcript: prior history, the input, and every
// assistant/tool turn. Feed it back via WithHistory to continue the
// conversation.
Messages []llm.Message
// Steps records each loop iteration.
Steps []Step
// Usage is the token total across all steps.
Usage llm.Usage
}
// Agent is a reusable model + system prompt + toolboxes (+ skills)
// composition. Configure at construction; AddSkill/AddToolbox may extend
// it later. Agents are safe to share across goroutines only after
// configuration is complete.
type Agent struct {
model llm.Model
system string
toolboxes []*llm.Toolbox
skills []Skill
maxSteps int
maxStepsFunc func() int
compactor func(ctx context.Context, msgs []llm.Message) ([]llm.Message, error)
maxConsecutiveToolErrors int
maxSameCallRepeats int
reqOpts []llm.Option
observers []func(Step)
}
// Option configures an Agent at construction.
type Option func(*Agent)
// WithToolbox attaches a toolbox.
func WithToolbox(b *llm.Toolbox) Option {
return func(a *Agent) { a.toolboxes = append(a.toolboxes, b) }
}
// WithTools attaches loose tools (wrapped in an anonymous toolbox).
func WithTools(tools ...llm.Tool) Option {
return func(a *Agent) { a.toolboxes = append(a.toolboxes, llm.NewToolbox("", tools...)) }
}
// WithSkill attaches a skill at construction (see also AddSkill).
func WithSkill(s Skill) Option {
return func(a *Agent) { a.skills = append(a.skills, s) }
}
// WithMaxSteps bounds the tool-dispatch loop.
func WithMaxSteps(n int) Option {
return func(a *Agent) { a.maxSteps = n }
}
// WithMaxStepsFunc makes the step ceiling dynamic: the function is
// consulted before every step, so a supervisor can extend (or shrink) a
// running agent's budget. It overrides WithMaxSteps while non-nil; a
// non-positive return falls back to the static value.
func WithMaxStepsFunc(fn func() int) Option {
return func(a *Agent) { a.maxStepsFunc = fn }
}
// WithCompactor installs a context-compaction hook, called with the full
// message slice before every model call; whatever it returns is sent
// instead (e.g. summarize the middle of a long transcript). A compactor
// error is non-fatal: the original messages are used.
func WithCompactor(fn func(ctx context.Context, msgs []llm.Message) ([]llm.Message, error)) Option {
return func(a *Agent) { a.compactor = fn }
}
// WithToolErrorLimits installs loop guards: maxConsecutiveErrors bounds
// successive steps whose tool results were ALL errors, and
// maxSameCallRepeats bounds identical (name + arguments) tool calls within
// one run. Either guard tripping ends the run with ErrToolLoop and the
// partial result. Zero disables a guard.
func WithToolErrorLimits(maxConsecutiveErrors, maxSameCallRepeats int) Option {
return func(a *Agent) {
a.maxConsecutiveToolErrors = maxConsecutiveErrors
a.maxSameCallRepeats = maxSameCallRepeats
}
}
// WithRequestOptions sets default request options (temperature, max
// tokens, ...) applied to every step of every run.
func WithRequestOptions(opts ...llm.Option) Option {
return func(a *Agent) { a.reqOpts = append(a.reqOpts, opts...) }
}
// WithStepObserver registers a callback invoked after every completed
// step (intermediate-step streaming for UIs, tracing, usage recording).
// Observers run synchronously in Run's goroutine.
func WithStepObserver(fn func(Step)) Option {
return func(a *Agent) { a.observers = append(a.observers, fn) }
}
// New creates an agent from a model and system prompt.
func New(model llm.Model, system string, opts ...Option) *Agent {
a := &Agent{model: model, system: system, maxSteps: DefaultMaxSteps}
for _, opt := range opts {
opt(a)
}
return a
}
// AddSkill attaches a skill to the agent on demand.
func (a *Agent) AddSkill(s Skill) { a.skills = append(a.skills, s) }
// AddToolbox attaches a toolbox to the agent on demand.
func (a *Agent) AddToolbox(b *llm.Toolbox) { a.toolboxes = append(a.toolboxes, b) }
// RunOption configures one Run.
type RunOption func(*runConfig)
type runConfig struct {
history []llm.Message
reqOpts []llm.Option
onStep []func(Step)
steer func() []llm.Message
}
// WithHistory seeds the run with prior conversation messages (e.g. a
// previous Result.Messages).
func WithHistory(msgs []llm.Message) RunOption {
return func(rc *runConfig) { rc.history = msgs }
}
// WithRunRequestOptions adds request options for this run only.
func WithRunRequestOptions(opts ...llm.Option) RunOption {
return func(rc *runConfig) { rc.reqOpts = append(rc.reqOpts, opts...) }
}
// OnStep registers a per-run step callback (in addition to agent-level
// observers).
func OnStep(fn func(Step)) RunOption {
return func(rc *runConfig) { rc.onStep = append(rc.onStep, fn) }
}
// WithSteer installs a steering source for this run: the function is
// drained before every step and any returned messages are appended to the
// conversation — the mechanism for a supervisor nudging a running agent
// ("wrap up", "focus on X"). It is called from Run's goroutine; the
// function owns its own synchronization.
func WithSteer(fn func() []llm.Message) RunOption {
return func(rc *runConfig) { rc.steer = fn }
}
// systemPrompt composes the agent's system prompt with each skill's
// instructions, in attachment order.
func (a *Agent) systemPrompt() string {
parts := make([]string, 0, 1+len(a.skills))
if a.system != "" {
parts = append(parts, a.system)
}
for _, s := range a.skills {
if ins := strings.TrimSpace(s.Instructions()); ins != "" {
parts = append(parts, ins)
}
}
return strings.Join(parts, "\n\n")
}
// mergedTools flattens toolboxes plus skill toolboxes into one toolset.
// Duplicate tool names are a configuration error and fail loudly — a
// silently shadowed tool is far harder to debug than this error.
func (a *Agent) mergedTools() (map[string]llm.Tool, []llm.Tool, error) {
byName := make(map[string]llm.Tool)
var ordered []llm.Tool
add := func(origin string, tools []llm.Tool) error {
for _, t := range tools {
if _, exists := byName[t.Name]; exists {
return fmt.Errorf("agent: duplicate tool %q (from %s)", t.Name, origin)
}
byName[t.Name] = t
ordered = append(ordered, t)
}
return nil
}
for _, b := range a.toolboxes {
if err := add("toolbox "+b.Name(), b.Tools()); err != nil {
return nil, nil, err
}
}
for _, s := range a.skills {
if b := s.Tools(); b != nil {
if err := add("skill "+s.Name(), b.Tools()); err != nil {
return nil, nil, err
}
}
}
return byName, ordered, nil
}
// Run executes the loop: send the conversation; while the model requests
// tools, execute them and feed results back; stop on a final answer,
// MaxSteps, or an unrecoverable model error.
func (a *Agent) Run(ctx context.Context, input string, opts ...RunOption) (*Result, error) {
var rc runConfig
for _, opt := range opts {
opt(&rc)
}
byName, ordered, err := a.mergedTools()
if err != nil {
return nil, err
}
msgs := append([]llm.Message(nil), rc.history...)
if input != "" {
msgs = append(msgs, llm.UserText(input))
}
if len(msgs) == 0 {
return nil, errors.New("agent: empty input and no history")
}
result := &Result{}
reqOpts := append(append([]llm.Option(nil), a.reqOpts...), rc.reqOpts...)
system := a.systemPrompt()
// Loop-guard state (WithToolErrorLimits).
consecutiveErrorSteps := 0
callCounts := make(map[string]int)
maxSteps := func() int {
if a.maxStepsFunc != nil {
if n := a.maxStepsFunc(); n > 0 {
return n
}
}
return a.maxSteps
}
for stepIdx := 0; stepIdx < maxSteps(); stepIdx++ {
// Steering: drain supervisor nudges into the conversation.
if rc.steer != nil {
msgs = append(msgs, rc.steer()...)
}
sendMsgs := msgs
if a.compactor != nil {
// Compaction failures are non-fatal: send the original.
if compacted, err := a.compactor(ctx, msgs); err == nil && compacted != nil {
sendMsgs = compacted
}
}
req := llm.Request{System: system, Messages: sendMsgs, Tools: ordered}
resp, err := a.model.Generate(ctx, req, reqOpts...)
if err != nil {
result.Messages = msgs
return result, fmt.Errorf("agent: step %d: %w", stepIdx, err)
}
msgs = append(msgs, resp.Message())
result.Usage.Add(resp.Usage)
step := Step{Index: stepIdx, Response: resp}
if len(resp.ToolCalls) == 0 {
// Final answer.
result.Output = resp.Text()
result.Steps = append(result.Steps, step)
result.Messages = msgs
a.notify(rc, step)
return result, nil
}
results := make([]llm.ToolResult, 0, len(resp.ToolCalls))
repeatTripped := ""
for _, call := range resp.ToolCalls {
if err := ctx.Err(); err != nil {
result.Messages = msgs
return result, err
}
if a.maxSameCallRepeats > 0 {
sig := call.Name + "\x00" + string(call.Arguments)
callCounts[sig]++
if callCounts[sig] > a.maxSameCallRepeats {
repeatTripped = call.Name
}
}
tool, ok := byName[call.Name]
if !ok {
results = append(results, llm.ToolResult{
ID: call.ID, Name: call.Name,
Content: fmt.Sprintf("unknown tool %q", call.Name),
IsError: true,
})
continue
}
// ExecuteTool recovers panics and converts errors to IsError
// results — the loop always continues.
results = append(results, llm.ExecuteTool(ctx, tool, call))
}
step.Results = results
result.Steps = append(result.Steps, step)
a.notify(rc, step)
msgs = append(msgs, llm.ToolResultsMessage(results...))
if repeatTripped != "" {
result.Messages = msgs
return result, fmt.Errorf("%w: %q called identically more than %d times",
ErrToolLoop, repeatTripped, a.maxSameCallRepeats)
}
allErrors := len(results) > 0
for _, r := range results {
if !r.IsError {
allErrors = false
break
}
}
if allErrors {
consecutiveErrorSteps++
if a.maxConsecutiveToolErrors > 0 && consecutiveErrorSteps >= a.maxConsecutiveToolErrors {
result.Messages = msgs
return result, fmt.Errorf("%w: %d consecutive steps with only failing tool calls",
ErrToolLoop, consecutiveErrorSteps)
}
} else {
consecutiveErrorSteps = 0
}
}
result.Messages = msgs
return result, fmt.Errorf("%w (max %d)", ErrMaxSteps, maxSteps())
}
// notify fans a step out to agent observers and run callbacks; observer
// panics are swallowed (the loop must never die for a UI callback).
func (a *Agent) notify(rc runConfig, step Step) {
emit := func(fn func(Step)) {
defer func() { _ = recover() }()
fn(step)
}
for _, fn := range a.observers {
emit(fn)
}
for _, fn := range rc.onStep {
emit(fn)
}
}