feat: foundations — canonical types, Parse grammar, env DSNs, health, chains
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>
This commit is contained in:
+165
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user