dcd004289f
Phase 1 of the majordomo build: - llm/ canonical contract (messages, parts, tools, capabilities, streaming, Model/Provider, error classification) - health/ clock-injected tracker (threshold bench, exponential capped cooldown, reset-on-success) - root Registry + Parse (verbatim model ids, inline recursive alias expansion with cycle detection, chain dedup), LLM_* env-DSN providers (go-llm parity: lazy fallback + eager LoadEnv), health-aware chain executor behind the Model interface - provider/fake scriptable test provider; hermetic test suite incl. the trailing-thinking chain and foreman:// env loading - ADRs 0001-0008, CLAUDE.md, README (honest matrix), CI workflow, docs/phase-1-design.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
4.5 KiB
Go
166 lines
4.5 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// Tool is a callable capability exposed to a model: a name, a description,
|
|
// JSON-Schema parameters, and a Go handler. Providers map this one canonical
|
|
// shape onto their native function-calling formats.
|
|
type Tool struct {
|
|
Name string
|
|
Description string
|
|
|
|
// Parameters is a JSON Schema object describing the tool's arguments.
|
|
// nil means the tool takes no arguments.
|
|
Parameters json.RawMessage
|
|
|
|
// Handler executes the tool. args is the raw JSON arguments object the
|
|
// model supplied. The returned value is JSON-encoded into the ToolResult.
|
|
Handler func(ctx context.Context, args json.RawMessage) (any, error)
|
|
}
|
|
|
|
// ToolCall is a model's request to invoke a tool.
|
|
type ToolCall struct {
|
|
// ID is the provider-assigned call id; majordomo synthesizes one for
|
|
// providers that do not supply ids. ToolResult.ID must echo it.
|
|
ID string
|
|
Name string
|
|
|
|
// Arguments is the raw JSON arguments object.
|
|
Arguments json.RawMessage
|
|
}
|
|
|
|
// ToolResult is the outcome of executing a ToolCall, sent back to the model.
|
|
type ToolResult struct {
|
|
// ID matches the originating ToolCall.ID.
|
|
ID string
|
|
Name string
|
|
|
|
// Content is the result serialized as text (JSON for structured values).
|
|
Content string
|
|
|
|
// IsError marks the result as a failure; the content then describes the
|
|
// error so the model can react (retry, apologize, try another tool).
|
|
IsError bool
|
|
}
|
|
|
|
// Toolbox is a named, ordered set of tools.
|
|
//
|
|
// Why: agents compose their available tools from several sources (multiple
|
|
// toolboxes plus skills); a small named container with duplicate detection
|
|
// keeps that merge explicit and debuggable.
|
|
type Toolbox struct {
|
|
name string
|
|
order []string
|
|
tools map[string]Tool
|
|
}
|
|
|
|
// NewToolbox creates a toolbox with the given name and initial tools.
|
|
// Duplicate tool names panic: toolboxes are assembled at startup, and a
|
|
// silently shadowed tool is a programming error worth failing loudly on.
|
|
func NewToolbox(name string, tools ...Tool) *Toolbox {
|
|
b := &Toolbox{name: name, tools: make(map[string]Tool, len(tools))}
|
|
for _, t := range tools {
|
|
if err := b.Add(t); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Name returns the toolbox name.
|
|
func (b *Toolbox) Name() string { return b.name }
|
|
|
|
// Add registers a tool, rejecting empty or duplicate names.
|
|
func (b *Toolbox) Add(t Tool) error {
|
|
if t.Name == "" {
|
|
return fmt.Errorf("toolbox %q: tool with empty name", b.name)
|
|
}
|
|
if _, exists := b.tools[t.Name]; exists {
|
|
return fmt.Errorf("toolbox %q: duplicate tool %q", b.name, t.Name)
|
|
}
|
|
b.tools[t.Name] = t
|
|
b.order = append(b.order, t.Name)
|
|
return nil
|
|
}
|
|
|
|
// Tools returns the tools in insertion order.
|
|
func (b *Toolbox) Tools() []Tool {
|
|
out := make([]Tool, 0, len(b.order))
|
|
for _, name := range b.order {
|
|
out = append(out, b.tools[name])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Get returns the named tool.
|
|
func (b *Toolbox) Get(name string) (Tool, bool) {
|
|
t, ok := b.tools[name]
|
|
return t, ok
|
|
}
|
|
|
|
// Execute runs the named tool for the given call and packages the outcome as
|
|
// a ToolResult. It never panics and never returns an error: handler errors
|
|
// and panics become IsError results so an agent loop can always continue.
|
|
func (b *Toolbox) Execute(ctx context.Context, call ToolCall) ToolResult {
|
|
t, ok := b.tools[call.Name]
|
|
if !ok {
|
|
return ToolResult{
|
|
ID: call.ID, Name: call.Name,
|
|
Content: fmt.Sprintf("unknown tool %q", call.Name),
|
|
IsError: true,
|
|
}
|
|
}
|
|
return ExecuteTool(ctx, t, call)
|
|
}
|
|
|
|
// ExecuteTool runs a single tool for the given call, recovering panics and
|
|
// converting errors into IsError results.
|
|
func ExecuteTool(ctx context.Context, t Tool, call ToolCall) (res ToolResult) {
|
|
res = ToolResult{ID: call.ID, Name: call.Name}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
res.Content = fmt.Sprintf("tool %q panicked: %v", call.Name, r)
|
|
res.IsError = true
|
|
}
|
|
}()
|
|
|
|
if t.Handler == nil {
|
|
res.Content = fmt.Sprintf("tool %q has no handler", call.Name)
|
|
res.IsError = true
|
|
return res
|
|
}
|
|
|
|
args := call.Arguments
|
|
if len(args) == 0 {
|
|
args = json.RawMessage("{}")
|
|
}
|
|
out, err := t.Handler(ctx, args)
|
|
if err != nil {
|
|
res.Content = err.Error()
|
|
res.IsError = true
|
|
return res
|
|
}
|
|
|
|
switch v := out.(type) {
|
|
case nil:
|
|
res.Content = "null"
|
|
case string:
|
|
res.Content = v
|
|
case json.RawMessage:
|
|
res.Content = string(v)
|
|
default:
|
|
enc, err := json.Marshal(v)
|
|
if err != nil {
|
|
res.Content = fmt.Sprintf("tool %q returned unencodable value: %v", call.Name, err)
|
|
res.IsError = true
|
|
return res
|
|
}
|
|
res.Content = string(enc)
|
|
}
|
|
return res
|
|
}
|