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:
2026-06-10 12:35:23 +02:00
parent 3025044817
commit dcd004289f
42 changed files with 3863 additions and 0 deletions
+165
View File
@@ -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
}