Files
majordomo/skill/calc/calc.go
T
steve 76ecf0e49e feat: skills — additive instruction+tool bundles, clock + calc examples
Phase 6: skill.New constructor satisfying the agent.Skill contract;
instruction-only skills; ordered additive composition; skill/clock
(injectable-clock time tools) and skill/calc (recursive-descent arithmetic
evaluator) as ready-made examples with full test suites incl. an
agent-loop round trip. ADR-0013; README skills section + matrix synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:13:07 +02:00

221 lines
4.7 KiB
Go

// Package calc is an example skill giving any agent exact arithmetic: a
// calculate tool evaluating +, -, *, /, %, ^, parentheses, and unary minus
// over floats — because models guess at arithmetic and tools don't.
package calc
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/skill"
)
// New builds the calc skill.
func New() *skill.Skill {
tool := llm.Tool{
Name: "calculate",
Description: "Evaluate an arithmetic expression exactly. Supports + - * / % ^ (power), parentheses, and unary minus. Use for ANY arithmetic instead of computing in your head.",
Parameters: json.RawMessage(`{
"type": "object",
"properties": {
"expression": {"type": "string", "description": "e.g. (2 + 3) * 4 / 1.5"}
},
"required": ["expression"]
}`),
Handler: func(_ context.Context, args json.RawMessage) (any, error) {
var p struct {
Expression string `json:"expression"`
}
if err := json.Unmarshal(args, &p); err != nil {
return nil, fmt.Errorf("bad arguments: %w", err)
}
v, err := Eval(p.Expression)
if err != nil {
return nil, err
}
return map[string]any{"expression": p.Expression, "result": v}, nil
},
}
return skill.New("calc",
skill.WithInstructions("You have a calculate tool. Use it for any arithmetic instead of estimating."),
skill.WithTools(tool),
)
}
// Eval evaluates an arithmetic expression with a small recursive-descent
// parser (no dependencies, no reflection, no eval).
//
// Grammar:
//
// expr := term (("+"|"-") term)*
// term := unary (("*"|"/"|"%") unary)*
// unary := "-" unary | power
// power := atom ("^" unary)? # right-associative
// atom := number | "(" expr ")"
func Eval(input string) (float64, error) {
p := &parser{src: input}
v, err := p.expr()
if err != nil {
return 0, err
}
p.skipSpace()
if p.pos < len(p.src) {
return 0, fmt.Errorf("unexpected %q at position %d", p.src[p.pos], p.pos)
}
if math.IsInf(v, 0) || math.IsNaN(v) {
return 0, fmt.Errorf("result of %q is not a finite number", input)
}
return v, nil
}
type parser struct {
src string
pos int
}
func (p *parser) skipSpace() {
for p.pos < len(p.src) && (p.src[p.pos] == ' ' || p.src[p.pos] == '\t') {
p.pos++
}
}
func (p *parser) peek() byte {
p.skipSpace()
if p.pos >= len(p.src) {
return 0
}
return p.src[p.pos]
}
func (p *parser) expr() (float64, error) {
v, err := p.term()
if err != nil {
return 0, err
}
for {
switch p.peek() {
case '+':
p.pos++
r, err := p.term()
if err != nil {
return 0, err
}
v += r
case '-':
p.pos++
r, err := p.term()
if err != nil {
return 0, err
}
v -= r
default:
return v, nil
}
}
}
func (p *parser) term() (float64, error) {
v, err := p.unary()
if err != nil {
return 0, err
}
for {
switch p.peek() {
case '*':
p.pos++
r, err := p.unary()
if err != nil {
return 0, err
}
v *= r
case '/':
p.pos++
r, err := p.unary()
if err != nil {
return 0, err
}
if r == 0 {
return 0, fmt.Errorf("division by zero")
}
v /= r
case '%':
p.pos++
r, err := p.unary()
if err != nil {
return 0, err
}
if r == 0 {
return 0, fmt.Errorf("modulo by zero")
}
v = math.Mod(v, r)
default:
return v, nil
}
}
}
func (p *parser) unary() (float64, error) {
if p.peek() == '-' {
p.pos++
v, err := p.unary()
if err != nil {
return 0, err
}
return -v, nil
}
return p.power()
}
func (p *parser) power() (float64, error) {
base, err := p.atom()
if err != nil {
return 0, err
}
if p.peek() == '^' {
p.pos++
exp, err := p.unary() // right-associative: 2^3^2 = 2^(3^2)
if err != nil {
return 0, err
}
return math.Pow(base, exp), nil
}
return base, nil
}
func (p *parser) atom() (float64, error) {
switch c := p.peek(); {
case c == '(':
p.pos++
v, err := p.expr()
if err != nil {
return 0, err
}
if p.peek() != ')' {
return 0, fmt.Errorf("missing closing parenthesis at position %d", p.pos)
}
p.pos++
return v, nil
case c >= '0' && c <= '9' || c == '.':
start := p.pos
for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || p.src[p.pos] == '.' ||
p.src[p.pos] == 'e' || p.src[p.pos] == 'E' ||
((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start && (p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) {
p.pos++
}
v, err := strconv.ParseFloat(strings.TrimSpace(p.src[start:p.pos]), 64)
if err != nil {
return 0, fmt.Errorf("bad number %q at position %d", p.src[start:p.pos], start)
}
return v, nil
case c == 0:
return 0, fmt.Errorf("unexpected end of expression")
default:
return 0, fmt.Errorf("unexpected %q at position %d", c, p.pos)
}
}