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 }