76ecf0e49e
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>
221 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|