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>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package calc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
)
|
||||
|
||||
func TestEval(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
want float64
|
||||
}{
|
||||
{"1+2", 3},
|
||||
{"2 * 3 + 4", 10},
|
||||
{"2 + 3 * 4", 14},
|
||||
{"(2 + 3) * 4", 20},
|
||||
{"10 / 4", 2.5},
|
||||
{"-3 + 5", 2},
|
||||
{"--3", 3},
|
||||
{"2^10", 1024},
|
||||
{"2^3^2", 512}, // right-associative
|
||||
{"-2^2", -4}, // unary binds looser than power
|
||||
{"7 % 3", 1},
|
||||
{"1.5e2 + 1", 151},
|
||||
{" ( 1 + 1 ) * ( 2 + 2 ) ", 8},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := Eval(tt.expr)
|
||||
if err != nil {
|
||||
t.Errorf("Eval(%q): %v", tt.expr, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("Eval(%q) = %v, want %v", tt.expr, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalErrors(t *testing.T) {
|
||||
for _, expr := range []string{"", "1/0", "5 % 0", "2 +", "(1+2", "1 + abc", "1 2", "2^99999"} {
|
||||
if _, err := Eval(expr); err == nil {
|
||||
t.Errorf("Eval(%q) should error", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillInAgentLoop: an agent actually invokes calculate and answers
|
||||
// from its result.
|
||||
func TestSkillInAgentLoop(t *testing.T) {
|
||||
fp := fake.New("fp")
|
||||
fp.Enqueue("m",
|
||||
fake.ReplyWith(llm.Response{
|
||||
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "calculate", Arguments: json.RawMessage(`{"expression":"(2+3)*4"}`)}},
|
||||
FinishReason: llm.FinishToolCalls,
|
||||
}),
|
||||
fake.Reply("the answer is 20"),
|
||||
)
|
||||
m, _ := fp.Model("m")
|
||||
|
||||
a := agent.New(m, "Math helper.", agent.WithSkill(New()))
|
||||
res, err := a.Run(context.Background(), "what is (2+3)*4?")
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Output != "the answer is 20" {
|
||||
t.Errorf("output = %q", res.Output)
|
||||
}
|
||||
result := res.Steps[0].Results[0]
|
||||
if result.IsError || !strings.Contains(result.Content, `"result":20`) {
|
||||
t.Errorf("tool result = %+v", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user