0147a79d18
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>
399 lines
13 KiB
Go
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)
|
|
}
|
|
}
|