feat(tools/basic): 内置基础工具(calculator(注入安全)/datetime/random/text/json/markdown)+ 批量注册
This commit is contained in:
1 parent
b3c61aa1ff
commit
08223c06a5
19 files changed
+2074
No files matched your search
@@ -0,0 +1,317 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxExprLen bounds an expression length so a request cannot force a parse/scan
|
||||
// beyond a trivial cost and cannot smuggle a larger payload through the tool.
|
||||
const maxExprLen = 256
|
||||
|
||||
// allowedChars lists the bytes permitted in an expression besides ASCII letters
|
||||
// (letters are only admitted to spell the sqrt function; the lexer rejects any
|
||||
// other identifier).
|
||||
const allowedChars = "0123456789.+-*/^() \t\n"
|
||||
|
||||
// calculator evaluates a safe arithmetic expression. It recognizes signed numeric
|
||||
// literals, + - * /, parentheses, the right-associative ^ power operator and the
|
||||
// sqrt(x) function. Every other character or identifier is rejected, so the
|
||||
// expression can never run arbitrary code. Division by zero and non-real results
|
||||
// are errors.
|
||||
type calculator struct{}
|
||||
|
||||
// Name returns the tool identifier.
|
||||
func (calculator) Name() string { return "calculator" }
|
||||
|
||||
// Description explains what the tool does for the model.
|
||||
func (calculator) Description() string {
|
||||
return "Safely evaluate an arithmetic expression using + - * /, parentheses, ^ (power) and sqrt(x)."
|
||||
}
|
||||
|
||||
// Parameters returns the tool input JSON Schema.
|
||||
func (calculator) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"expression": map[string]any{"type": "string", "description": "the arithmetic expression to evaluate"},
|
||||
},
|
||||
"required": []any{"expression"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute evaluates expression and returns {"expression":..., "result":...}.
|
||||
func (calculator) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := asMap(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expr, err := reqString(m, "expression")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val, err := eval(expr)
|
||||
if err != nil {
|
||||
log().Error("calculator failed", "expression", expr, "reason", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"expression": expr, "result": val}, nil
|
||||
}
|
||||
|
||||
// eval validates, tokenizes and parses expr, returning its numeric value.
|
||||
func eval(expr string) (float64, error) {
|
||||
if expr == "" {
|
||||
return 0, fmt.Errorf("%w: expression is empty", ErrEvaluation)
|
||||
}
|
||||
if len(expr) > maxExprLen {
|
||||
return 0, fmt.Errorf("%w: expression exceeds %d characters", ErrEvaluation, maxExprLen)
|
||||
}
|
||||
for _, c := range expr {
|
||||
if strings.ContainsRune(allowedChars, c) {
|
||||
continue
|
||||
}
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' {
|
||||
continue // letter: the lexer admits only "sqrt"
|
||||
}
|
||||
return 0, fmt.Errorf("%w: disallowed character %q", ErrEvaluation, c)
|
||||
}
|
||||
toks, err := lex(expr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p := &parser{toks: toks}
|
||||
return p.parse()
|
||||
}
|
||||
|
||||
func isAsciiLetter(c byte) bool {
|
||||
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||||
}
|
||||
|
||||
// tokKind describes a token category produced by the lexer.
|
||||
type tokKind int
|
||||
|
||||
const (
|
||||
tokEOF tokKind = iota
|
||||
tokNum
|
||||
tokOp
|
||||
tokLParen
|
||||
tokRParen
|
||||
tokIdent
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokKind
|
||||
text string
|
||||
val float64
|
||||
}
|
||||
|
||||
// lex scans s into a token stream terminated by tokEOF. Any ASCII letter run that
|
||||
// is not exactly "sqrt" is rejected.
|
||||
func lex(s string) ([]token, error) {
|
||||
toks := make([]token, 0, len(s)/2+1)
|
||||
for i := 0; i < len(s); {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == ' ' || c == '\t' || c == '\n':
|
||||
i++
|
||||
case c == '(':
|
||||
toks = append(toks, token{kind: tokLParen, text: "("})
|
||||
i++
|
||||
case c == ')':
|
||||
toks = append(toks, token{kind: tokRParen, text: ")"})
|
||||
i++
|
||||
case c == '+' || c == '-' || c == '*' || c == '/' || c == '^':
|
||||
toks = append(toks, token{kind: tokOp, text: string(c)})
|
||||
i++
|
||||
case isAsciiLetter(c):
|
||||
start := i
|
||||
for i < len(s) && isAsciiLetter(s[i]) {
|
||||
i++
|
||||
}
|
||||
name := s[start:i]
|
||||
if name != "sqrt" {
|
||||
return nil, fmt.Errorf("%w: unexpected identifier %q", ErrEvaluation, name)
|
||||
}
|
||||
toks = append(toks, token{kind: tokIdent, text: name})
|
||||
case c >= '0' && c <= '9' || c == '.':
|
||||
start := i
|
||||
dot := false
|
||||
for i < len(s) {
|
||||
d := s[i]
|
||||
if d >= '0' && d <= '9' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if d == '.' && !dot {
|
||||
dot = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
raw := s[start:i]
|
||||
val, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid number %q", ErrEvaluation, raw)
|
||||
}
|
||||
toks = append(toks, token{kind: tokNum, text: raw, val: val})
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: disallowed character %q", ErrEvaluation, c)
|
||||
}
|
||||
}
|
||||
toks = append(toks, token{kind: tokEOF, text: ""})
|
||||
return toks, nil
|
||||
}
|
||||
|
||||
// parser is a recursive-descent parser over the token stream. Grammar:
|
||||
//
|
||||
// expr := term (('+'|'-') term)*
|
||||
// term := unary (('*'|'/') unary)*
|
||||
// unary := ('+'|'-') unary | power
|
||||
// power := primary ('^' unary)?
|
||||
//
|
||||
// ^ binds tighter than unary minus (so -2^2 == -(2^2)) and is right-associative.
|
||||
type parser struct {
|
||||
toks []token
|
||||
pos int
|
||||
}
|
||||
|
||||
func (p *parser) cur() token { return p.toks[p.pos] }
|
||||
|
||||
func (p *parser) parse() (float64, error) {
|
||||
v, err := p.expr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.cur().kind != tokEOF {
|
||||
return 0, fmt.Errorf("%w: unexpected token %q", ErrEvaluation, p.cur().text)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (p *parser) expr() (float64, error) {
|
||||
v, err := p.term()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for p.cur().kind == tokOp && (p.cur().text == "+" || p.cur().text == "-") {
|
||||
op := p.cur().text
|
||||
p.pos++
|
||||
r, err := p.term()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if op == "+" {
|
||||
v += r
|
||||
} else {
|
||||
v -= r
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (p *parser) term() (float64, error) {
|
||||
v, err := p.unary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for p.cur().kind == tokOp && (p.cur().text == "*" || p.cur().text == "/") {
|
||||
op := p.cur().text
|
||||
p.pos++
|
||||
r, err := p.unary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if op == "*" {
|
||||
v *= r
|
||||
} else {
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("%w: division by zero", ErrEvaluation)
|
||||
}
|
||||
v /= r
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (p *parser) unary() (float64, error) {
|
||||
if p.cur().kind == tokOp && (p.cur().text == "+" || p.cur().text == "-") {
|
||||
neg := p.cur().text == "-"
|
||||
p.pos++
|
||||
v, err := p.unary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if neg {
|
||||
return -v, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
return p.power()
|
||||
}
|
||||
|
||||
func (p *parser) power() (float64, error) {
|
||||
base, err := p.primary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.cur().kind == tokOp && p.cur().text == "^" {
|
||||
p.pos++
|
||||
exp, err := p.unary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r := math.Pow(base, exp)
|
||||
if math.IsNaN(r) {
|
||||
return 0, fmt.Errorf("%w: non-real result", ErrEvaluation)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (p *parser) primary() (float64, error) {
|
||||
t := p.cur()
|
||||
switch t.kind {
|
||||
case tokNum:
|
||||
p.pos++
|
||||
return t.val, nil
|
||||
case tokLParen:
|
||||
p.pos++
|
||||
v, err := p.expr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.cur().kind != tokRParen {
|
||||
return 0, fmt.Errorf("%w: missing closing parenthesis", ErrEvaluation)
|
||||
}
|
||||
p.pos++
|
||||
return v, nil
|
||||
case tokIdent:
|
||||
p.pos++
|
||||
if p.cur().kind != tokLParen {
|
||||
return 0, fmt.Errorf("%w: sqrt requires parentheses", ErrEvaluation)
|
||||
}
|
||||
p.pos++
|
||||
v, err := p.expr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.cur().kind != tokRParen {
|
||||
return 0, fmt.Errorf("%w: missing closing parenthesis in sqrt", ErrEvaluation)
|
||||
}
|
||||
p.pos++
|
||||
if v < 0 {
|
||||
return 0, fmt.Errorf("%w: sqrt of a negative number", ErrEvaluation)
|
||||
}
|
||||
return math.Sqrt(v), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("%w: unexpected token %q", ErrEvaluation, t.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCalculator_Evaluate_Normal covers the arithmetic grammar.
|
||||
func TestCalculator_Evaluate_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := calculator{}
|
||||
cases := []struct {
|
||||
expr string
|
||||
want float64
|
||||
}{
|
||||
{expr: "1+2*3", want: 7},
|
||||
{expr: "(1+2)*3", want: 9},
|
||||
{expr: "2^10", want: 1024},
|
||||
{expr: "2^3^2", want: 512}, // right-associative
|
||||
{expr: "-2^2", want: -4}, // power binds tighter than unary minus
|
||||
{expr: "2^-3", want: 0.125}, // negative exponent
|
||||
{expr: "sqrt(16)", want: 4},
|
||||
{expr: "sqrt(2)+1", want: 2.414213562373095},
|
||||
{expr: "10/4", want: 2.5},
|
||||
{expr: " ( 5 + 3 ) * 2 ", want: 16},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.expr, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, c, map[string]any{"expression": tc.expr})
|
||||
assertNoErr(t, err)
|
||||
assertApprox(t, fieldFloat(t, mapResult(t, got), "result"), tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculator_Evaluate_Errors covers invalid expressions and missing/typed args.
|
||||
func TestCalculator_Evaluate_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := calculator{}
|
||||
byExpression := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{name: "missing expression", args: map[string]any{}},
|
||||
{name: "non-string expression", args: map[string]any{"expression": 42}},
|
||||
{name: "division by zero", args: map[string]any{"expression": "1/0"}},
|
||||
{name: "trailing operator", args: map[string]any{"expression": "1+"}},
|
||||
{name: "bare identifier", args: map[string]any{"expression": "x+1"}},
|
||||
{name: "unknown function", args: map[string]any{"expression": "sqrt(16"}},
|
||||
{name: "negative sqrt", args: map[string]any{"expression": "sqrt(-1)"}},
|
||||
{name: "empty string", args: map[string]any{"expression": ""}},
|
||||
{name: "non-ASCII injection", args: map[string]any{"expression": "1+2;DROP"}},
|
||||
}
|
||||
for _, tc := range byExpression {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := run(t, c, tc.args)
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidArgument) && !errors.Is(err, ErrEvaluation) {
|
||||
t.Fatalf("err %v wraps neither ErrInvalidArgument nor ErrEvaluation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculator_InjectionRejected ensures expression injection is refused.
|
||||
func TestCalculator_InjectionRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := calculator{}
|
||||
payloads := []string{
|
||||
"os.system('id')",
|
||||
"process.env.HOME",
|
||||
`__import__("os")`,
|
||||
"1+2*3;rm -rf /",
|
||||
"`id`",
|
||||
"eval(1)",
|
||||
"1 && 2",
|
||||
"len(open('x'))",
|
||||
}
|
||||
for _, p := range payloads {
|
||||
p := p
|
||||
t.Run(p, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := run(t, c, map[string]any{"expression": p})
|
||||
assertIs(t, err, ErrEvaluation)
|
||||
})
|
||||
}
|
||||
// The expression length bound rejects a long payload.
|
||||
large := strings.Repeat("1+", 200) + "1"
|
||||
_, err := run(t, c, map[string]any{"expression": large})
|
||||
assertIs(t, err, ErrEvaluation)
|
||||
}
|
||||
|
||||
// TestCalculator_Schema verifies the JSON Schema contract.
|
||||
func TestCalculator_Schema(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := calculator{}.Parameters()
|
||||
assertNoErr(t, err)
|
||||
reqs := p["required"].([]any)
|
||||
if len(reqs) != 1 || reqs[0] != "expression" {
|
||||
t.Fatalf("required = %v, want [expression]", reqs)
|
||||
}
|
||||
props := p["properties"].(map[string]any)
|
||||
if got := props["expression"].(map[string]any)["type"]; got != "string" {
|
||||
t.Fatalf("expression type = %v, want string", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExecute_HonorsCancellation verifies every tool returns the context error
|
||||
// when ctx is already canceled, without performing any work.
|
||||
func TestExecute_HonorsCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
for _, tool := range Tools() {
|
||||
tool := tool
|
||||
t.Run(tool.Name(), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := tool.Execute(ctx, map[string]any{})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want wrapping context.Canceled", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// datetime returns the current time, optionally formatted with a Go reference
|
||||
// layout and rendered in a named IANA timezone.
|
||||
type datetime struct{}
|
||||
|
||||
const (
|
||||
// defaultDTFormat is the layout used when the caller supplies none.
|
||||
defaultDTFormat = time.RFC3339
|
||||
// defaultDTZone is the timezone used when the caller supplies none.
|
||||
defaultDTZone = "UTC"
|
||||
// maxDTFormatLen bounds a layout string to guard against absurd input.
|
||||
maxDTFormatLen = 128
|
||||
// maxDTZoneLen bounds a timezone name to guard against absurd input.
|
||||
maxDTZoneLen = 64
|
||||
)
|
||||
|
||||
// Name returns the tool identifier.
|
||||
func (datetime) Name() string { return "datetime" }
|
||||
|
||||
// Description explains what the tool does for the model.
|
||||
func (datetime) Description() string {
|
||||
return "Return the current time, formatted with a Go reference layout and rendered in a named IANA timezone."
|
||||
}
|
||||
|
||||
// Parameters returns the tool input JSON Schema.
|
||||
func (datetime) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"format": map[string]any{"type": "string", "description": "Go reference layout, e.g. 2006-01-02 15:04:05"},
|
||||
"timezone": map[string]any{"type": "string", "description": "IANA timezone name, e.g. Asia/Shanghai"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute returns the current time in the requested format and timezone.
|
||||
func (datetime) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := asMap(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layout, err := optString(m, "format", defaultDTFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(layout) > maxDTFormatLen {
|
||||
return nil, fmt.Errorf("basic: layout too long: %w", ErrInvalidArgument)
|
||||
}
|
||||
zone, err := optString(m, "timezone", defaultDTZone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(zone) > maxDTZoneLen {
|
||||
return nil, fmt.Errorf("basic: timezone name too long: %w", ErrInvalidArgument)
|
||||
}
|
||||
loc, err := time.LoadLocation(zone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("basic: invalid timezone %q: %w", zone, err)
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
return map[string]any{
|
||||
"now": now.Format(layout),
|
||||
"unix": now.Unix(),
|
||||
"timezone": zone,
|
||||
"format": layout,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDatetime_Execute_Normal covers default, formatted and zone-shifted output.
|
||||
func TestDatetime_Execute_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := datetime{}
|
||||
|
||||
t.Run("default", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, c, nil)
|
||||
assertNoErr(t, err)
|
||||
m := mapResult(t, got)
|
||||
if fieldString(t, m, "timezone") != "UTC" {
|
||||
t.Fatalf("timezone = %v, want UTC", m["timezone"])
|
||||
}
|
||||
if fieldString(t, m, "format") != time.RFC3339 {
|
||||
t.Fatalf("format = %v, want RFC3339", m["format"])
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, fieldString(t, m, "now")); err != nil {
|
||||
t.Fatalf("now %q is not RFC3339: %v", m["now"], err)
|
||||
}
|
||||
if fieldInt64(t, m, "unix") <= 0 {
|
||||
t.Fatalf("unix = %v, want positive", m["unix"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, c, map[string]any{"format": "2006-01-02"})
|
||||
assertNoErr(t, err)
|
||||
m := mapResult(t, got)
|
||||
if len(fieldString(t, m, "now")) != 10 {
|
||||
t.Fatalf("now = %v, want 10-char date", m["now"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-UTC zone", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := time.LoadLocation("Asia/Shanghai"); err != nil {
|
||||
t.Skipf("tzdata not available: %v", err)
|
||||
}
|
||||
got, err := run(t, c, map[string]any{"timezone": "Asia/Shanghai"})
|
||||
assertNoErr(t, err)
|
||||
m := mapResult(t, got)
|
||||
if fieldString(t, m, "timezone") != "Asia/Shanghai" {
|
||||
t.Fatalf("timezone = %v, want Asia/Shanghai", m["timezone"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDatetime_Execute_Errors covers invalid zones and overlong inputs.
|
||||
func TestDatetime_Execute_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := datetime{}
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
target error
|
||||
}{
|
||||
{name: "invalid zone", args: map[string]any{"timezone": "Not/AZone"}, target: nil},
|
||||
{name: "zone too long", args: map[string]any{"timezone": string(make([]byte, 65))}, target: ErrInvalidArgument},
|
||||
{name: "layout too long", args: map[string]any{"format": string(make([]byte, 129))}, target: ErrInvalidArgument},
|
||||
{name: "non-string timezone", args: map[string]any{"timezone": 123}, target: ErrInvalidArgument},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := run(t, c, tc.args)
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got nil")
|
||||
}
|
||||
if tc.target != nil {
|
||||
assertIs(t, err, tc.target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatetime_Schema verifies the JSON Schema contract.
|
||||
func TestDatetime_Schema(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := datetime{}.Parameters()
|
||||
assertNoErr(t, err)
|
||||
if _, ok := p["required"].([]any); ok {
|
||||
t.Fatalf("datetime schema should have no required fields")
|
||||
}
|
||||
props := p["properties"].(map[string]any)
|
||||
for _, field := range []string{"format", "timezone"} {
|
||||
if props[field].(map[string]any)["type"] != "string" {
|
||||
t.Fatalf("%s type should be string", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package basic implements the built-in foundation tools for Cyrene v2
|
||||
// (DevDocs/design/09-插件生态.md, DevDocs/开发时间线.md M5): calculator,
|
||||
// datetime, random, text, json and markdown.
|
||||
//
|
||||
// Each tool is an sdk.Tool that is registered through the capability registry
|
||||
// via Register, and all of them are stateless values, so they are safe for
|
||||
// concurrent use. The tools are pure — no I/O and no network — so they can be
|
||||
// invoked from any role, any mode and any channel without sandboxing.
|
||||
package basic
|
||||
@@ -0,0 +1,104 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// executor is the subset of sdk.Tool used in tests so concrete tools can be
|
||||
// exercised without importing the SDK into the test table.
|
||||
type executor interface {
|
||||
Execute(context.Context, any) (any, error)
|
||||
}
|
||||
|
||||
// run executes e with args against a background context.
|
||||
func run(t *testing.T, e executor, args map[string]any) (any, error) {
|
||||
t.Helper()
|
||||
return e.Execute(context.Background(), args)
|
||||
}
|
||||
|
||||
// assertIs fails when err does not wrap target.
|
||||
func assertIs(t *testing.T, err, target error) {
|
||||
t.Helper()
|
||||
if !errors.Is(err, target) {
|
||||
t.Fatalf("errors.Is(%v, %v) = false, want true", err, target)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoErr fails when err is non-nil.
|
||||
func assertNoErr(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mapResult asserts got is a map[string]any and returns it.
|
||||
func mapResult(t *testing.T, got any) map[string]any {
|
||||
t.Helper()
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result is %T, want map[string]any", got)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// fieldString returns m[key] as a string, failing otherwise.
|
||||
func fieldString(t *testing.T, m map[string]any, key string) string {
|
||||
t.Helper()
|
||||
v, ok := m[key].(string)
|
||||
if !ok {
|
||||
t.Fatalf("%s field is %T (%v), want string", key, m[key], m[key])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// fieldFloat returns m[key] as a float64, failing otherwise.
|
||||
func fieldFloat(t *testing.T, m map[string]any, key string) float64 {
|
||||
t.Helper()
|
||||
v, ok := m[key].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("%s field is %T (%v), want float64", key, m[key], m[key])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// fieldInt returns m[key] as an int, failing otherwise.
|
||||
func fieldInt(t *testing.T, m map[string]any, key string) int {
|
||||
t.Helper()
|
||||
v, ok := m[key].(int)
|
||||
if !ok {
|
||||
t.Fatalf("%s field is %T (%v), want int", key, m[key], m[key])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// fieldInt64 returns m[key] as an int64, failing otherwise.
|
||||
func fieldInt64(t *testing.T, m map[string]any, key string) int64 {
|
||||
t.Helper()
|
||||
v, ok := m[key].(int64)
|
||||
if !ok {
|
||||
t.Fatalf("%s field is %T (%v), want int64", key, m[key], m[key])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// fieldBool returns m[key] as a bool, failing otherwise.
|
||||
func fieldBool(t *testing.T, m map[string]any, key string) bool {
|
||||
t.Helper()
|
||||
v, ok := m[key].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("%s field is %T (%v), want bool", key, m[key], m[key])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// assertApprox fails when got and want differ by more than 1e-9.
|
||||
func assertApprox(t *testing.T, got, want float64) {
|
||||
t.Helper()
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// jsonTool exposes JSON parsing, formatting, validation and serialization for the
|
||||
// agent. `value` is unconstrained in the schema because it is a string for
|
||||
// parse/format/validate and any JSON value for stringify.
|
||||
type jsonTool struct{}
|
||||
|
||||
// Name returns the tool identifier.
|
||||
func (jsonTool) Name() string { return "json" }
|
||||
|
||||
// Description explains what the tool does for the model.
|
||||
func (jsonTool) Description() string {
|
||||
return "Parse, format, validate or serialize JSON for structured data handling."
|
||||
}
|
||||
|
||||
// Parameters returns the tool input JSON Schema.
|
||||
func (jsonTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"operation": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []any{"parse", "format", "validate", "stringify"},
|
||||
"description": "the operation to perform",
|
||||
},
|
||||
"value": map[string]any{"description": "input: a JSON string for parse/format/validate, any value for stringify"},
|
||||
},
|
||||
"required": []any{"operation"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute runs the requested operation against value.
|
||||
func (jsonTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := asMap(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op, err := reqString(m, "operation")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasKey(m, "value") {
|
||||
return nil, fmt.Errorf("basic: missing required argument %q: %w", "value", ErrInvalidArgument)
|
||||
}
|
||||
value := m["value"]
|
||||
switch strings.ToLower(strings.TrimSpace(op)) {
|
||||
case "parse":
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q must be a string for parse: %w", "value", ErrInvalidArgument)
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil, fmt.Errorf("basic: invalid json for parse: %w", err)
|
||||
}
|
||||
return map[string]any{"result": out}, nil
|
||||
case "format":
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q must be a string for format: %w", "value", ErrInvalidArgument)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, []byte(s), "", " "); err != nil {
|
||||
return nil, fmt.Errorf("basic: invalid json for format: %w", err)
|
||||
}
|
||||
return map[string]any{"result": buf.String()}, nil
|
||||
case "validate":
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q must be a string for validate: %w", "value", ErrInvalidArgument)
|
||||
}
|
||||
return map[string]any{"valid": json.Valid([]byte(s))}, nil
|
||||
case "stringify":
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(value); err != nil {
|
||||
return nil, fmt.Errorf("basic: cannot serialize value: %w", err)
|
||||
}
|
||||
return map[string]any{"result": strings.TrimRight(buf.String(), "\n")}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("basic: unknown json operation %q: %w", op, ErrUnsupported)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJson_Execute_Normal covers parse, format, validate and stringify.
|
||||
func TestJson_Execute_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := jsonTool{}
|
||||
|
||||
t.Run("parse", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "parse", "value": `{"a":1,"b":[true,null,"s"]}`})
|
||||
assertNoErr(t, err)
|
||||
m := mapResult(t, got)
|
||||
obj := m["result"].(map[string]any)
|
||||
if obj["a"] != float64(1) {
|
||||
t.Fatalf("a = %v, want 1", obj["a"])
|
||||
}
|
||||
if !reflect.DeepEqual(obj["b"], []any{true, nil, "s"}) {
|
||||
t.Fatalf("b = %v, want [true nil s]", obj["b"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string value parse", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "parse", "value": `"hi"`})
|
||||
assertNoErr(t, err)
|
||||
if fieldString(t, mapResult(t, got), "result") != "hi" {
|
||||
t.Fatalf("result should be hi")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "format", "value": `{"x":1}`})
|
||||
assertNoErr(t, err)
|
||||
m := mapResult(t, got)
|
||||
if want := "{\n \"x\": 1\n}"; fieldString(t, m, "result") != want {
|
||||
t.Fatalf("result = %q, want %q", m["result"], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "validate", "value": `{"a":[1,2]}`})
|
||||
assertNoErr(t, err)
|
||||
if !fieldBool(t, mapResult(t, got), "valid") {
|
||||
t.Fatalf("valid should be true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "validate", "value": `{broken`})
|
||||
assertNoErr(t, err)
|
||||
if fieldBool(t, mapResult(t, got), "valid") {
|
||||
t.Fatalf("valid should be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stringify", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "stringify", "value": map[string]any{"x": 1}})
|
||||
assertNoErr(t, err)
|
||||
m := mapResult(t, got)
|
||||
if want := "{\n \"x\": 1\n}"; fieldString(t, m, "result") != want {
|
||||
t.Fatalf("result = %q, want %q", m["result"], want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestJson_Execute_Errors covers invalid inputs and unsupported operations.
|
||||
func TestJson_Execute_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := jsonTool{}
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
target error
|
||||
}{
|
||||
{name: "invalid parse", args: map[string]any{"operation": "parse", "value": `{bad`}, target: nil},
|
||||
{name: "parse non-string", args: map[string]any{"operation": "parse", "value": 42}, target: ErrInvalidArgument},
|
||||
{name: "invalid format", args: map[string]any{"operation": "format", "value": `{bad`}, target: nil},
|
||||
{name: "validate non-string", args: map[string]any{"operation": "validate", "value": true}, target: ErrInvalidArgument},
|
||||
{name: "unknown operation", args: map[string]any{"operation": "csv", "value": "{}"}, target: ErrUnsupported},
|
||||
{name: "missing value", args: map[string]any{"operation": "stringify"}, target: ErrInvalidArgument},
|
||||
{name: "missing operation", args: map[string]any{}, target: ErrInvalidArgument},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := run(t, tool, tc.args)
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got nil")
|
||||
}
|
||||
if tc.target != nil {
|
||||
assertIs(t, err, tc.target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJson_Schema verifies required operation.
|
||||
func TestJson_Schema(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := jsonTool{}.Parameters()
|
||||
assertNoErr(t, err)
|
||||
reqs := p["required"].([]any)
|
||||
if len(reqs) != 1 || reqs[0] != "operation" {
|
||||
t.Fatalf("required = %v, want [operation]", reqs)
|
||||
}
|
||||
if _, ok := p["properties"].(map[string]any)["value"]; !ok {
|
||||
t.Fatalf("schema should include a value property")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// log returns a logger tagged with this plugin's source so every record it emits
|
||||
// is traceable back to its owning module (DevDocs/02-development-constraints.md
|
||||
// §五). The sdk.Tool surface does not carry an injected base logger, so a fresh
|
||||
// one is built on demand; it is only used on the error path, where the cost is
|
||||
// negligible. Errors are also returned to the caller so the failure is never
|
||||
// swallowed.
|
||||
func log() *slog.Logger {
|
||||
return logger.WithSource(logger.New(logger.Options{}), "plugins/tools/basic")
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// markdown exposes Markdown building and parsing helpers: tables, lists, headings
|
||||
// and YAML front-matter metadata.
|
||||
type markdown struct{}
|
||||
|
||||
const (
|
||||
// minHeadingLevel and maxHeadingLevel bound the supported ATX heading depth.
|
||||
minHeadingLevel = 1
|
||||
maxHeadingLevel = 6
|
||||
)
|
||||
|
||||
// Name returns the tool identifier.
|
||||
func (markdown) Name() string { return "markdown" }
|
||||
|
||||
// Description explains what the tool does for the model.
|
||||
func (markdown) Description() string {
|
||||
return "Build or parse Markdown: tables, lists, headings and YAML front-matter metadata."
|
||||
}
|
||||
|
||||
// Parameters returns the tool input JSON Schema. Operation-specific inputs are
|
||||
// validated by Execute.
|
||||
func (markdown) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"operation": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []any{"table", "list", "heading", "metadata"},
|
||||
"description": "the operation to perform",
|
||||
},
|
||||
"content": map[string]any{"type": "string", "description": "Markdown body for metadata parsing"},
|
||||
"headers": map[string]any{"type": "array", "description": "table column headers"},
|
||||
"rows": map[string]any{"type": "array", "description": "table rows (array of arrays of strings)"},
|
||||
"items": map[string]any{"type": "array", "description": "list entries"},
|
||||
"ordered": map[string]any{"type": "boolean", "description": "render a numbered list when true"},
|
||||
"level": map[string]any{"type": "integer", "description": "heading level 1..6"},
|
||||
"text": map[string]any{"type": "string", "description": "heading text"},
|
||||
},
|
||||
"required": []any{"operation"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute runs the requested operation.
|
||||
func (markdown) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := asMap(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op, err := reqString(m, "operation")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(op)) {
|
||||
case "table":
|
||||
return mdTable(m)
|
||||
case "list":
|
||||
return mdList(m)
|
||||
case "heading":
|
||||
return mdHeading(m)
|
||||
case "metadata":
|
||||
return mdMeta(m)
|
||||
default:
|
||||
return nil, fmt.Errorf("basic: unknown markdown operation %q: %w", op, ErrUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
// mdTable builds a Markdown table from headers and rows.
|
||||
func mdTable(m map[string]any) (any, error) {
|
||||
headers, err := strSliceArg(m, "headers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := strSliceSliceArg(m, "rows")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b strings.Builder
|
||||
write := func(cells []string) {
|
||||
b.WriteString("| ")
|
||||
for i, c := range cells {
|
||||
if i > 0 {
|
||||
b.WriteString(" | ")
|
||||
}
|
||||
b.WriteString(escapeCell(c))
|
||||
}
|
||||
b.WriteString(" |\n")
|
||||
}
|
||||
write(headers)
|
||||
b.WriteString(tableSeparator(headers))
|
||||
for _, r := range rows {
|
||||
write(r)
|
||||
}
|
||||
return map[string]any{"result": b.String()}, nil
|
||||
}
|
||||
|
||||
// tableSeparator returns the | --- | --- | header separator row for the column count.
|
||||
func tableSeparator(cols []string) string {
|
||||
parts := make([]string, len(cols))
|
||||
for i := range parts {
|
||||
parts[i] = "---"
|
||||
}
|
||||
return "| " + strings.Join(parts, " | ") + " |\n"
|
||||
}
|
||||
|
||||
// escapeCell neutralizes a cell so a pipe inside the value does not break the table.
|
||||
func escapeCell(s string) string {
|
||||
s = strings.ReplaceAll(s, "|", `\|`)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.ReplaceAll(s, "\r", "")
|
||||
}
|
||||
|
||||
// mdList builds a bullet or numbered list from items.
|
||||
func mdList(m map[string]any) (any, error) {
|
||||
items, err := strSliceArg(m, "items")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ordered, err := optBool(m, "ordered", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, it := range items {
|
||||
if ordered {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, it)
|
||||
} else {
|
||||
b.WriteString("- " + it + "\n")
|
||||
}
|
||||
}
|
||||
return map[string]any{"result": b.String()}, nil
|
||||
}
|
||||
|
||||
// mdHeading builds a Markdown ATX heading.
|
||||
func mdHeading(m map[string]any) (any, error) {
|
||||
level, err := optInt(m, "level", 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, err := reqString(m, "text")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if level < minHeadingLevel || level > maxHeadingLevel {
|
||||
return nil, fmt.Errorf("basic: heading level must be between %d and %d: %w", minHeadingLevel, maxHeadingLevel, ErrInvalidArgument)
|
||||
}
|
||||
return map[string]any{"result": strings.Repeat("#", level) + " " + text + "\n"}, nil
|
||||
}
|
||||
|
||||
// mdMeta parses YAML front matter from content and returns the metadata table plus
|
||||
// the remaining body.
|
||||
func mdMeta(m map[string]any) (any, error) {
|
||||
content, err := reqString(m, "content")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, body := parseFrontMatter(content)
|
||||
return map[string]any{"meta": meta, "body": body}, nil
|
||||
}
|
||||
|
||||
// parseFrontMatter splits leading `--- ... ---` front matter from a Markdown body.
|
||||
// When the document has no front matter it returns an empty table and the whole
|
||||
// content as the body.
|
||||
func parseFrontMatter(content string) (map[string]string, string) {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return map[string]string{}, content
|
||||
}
|
||||
meta := map[string]string{}
|
||||
i := 1
|
||||
for i < len(lines) {
|
||||
line := strings.TrimRight(lines[i], "\r")
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if key, val, ok := strings.Cut(line, ":"); ok {
|
||||
meta[strings.TrimSpace(key)] = strings.TrimSpace(val)
|
||||
}
|
||||
i++
|
||||
}
|
||||
body := strings.Join(lines[i:], "\n")
|
||||
return meta, body
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMarkdown_Execute_Normal covers table, list, heading and metadata ops.
|
||||
func TestMarkdown_Execute_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := markdown{}
|
||||
|
||||
t.Run("table", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{
|
||||
"operation": "table",
|
||||
"headers": []any{"name", "score"},
|
||||
"rows": []any{[]any{"ada", "90"}, []any{"grace", "100"}},
|
||||
})
|
||||
assertNoErr(t, err)
|
||||
want := "| name | score |\n| --- | --- |\n| ada | 90 |\n| grace | 100 |\n"
|
||||
if got := fieldString(t, mapResult(t, got), "result"); got != want {
|
||||
t.Fatalf("table = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bulleted list", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "list", "items": []any{"a", "b"}})
|
||||
assertNoErr(t, err)
|
||||
if want := "- a\n- b\n"; fieldString(t, mapResult(t, got), "result") != want {
|
||||
t.Fatalf("got %q", fieldString(t, mapResult(t, got), "result"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordered list", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "list", "items": []any{"a", "b"}, "ordered": true})
|
||||
assertNoErr(t, err)
|
||||
if want := "1. a\n2. b\n"; fieldString(t, mapResult(t, got), "result") != want {
|
||||
t.Fatalf("got %q", fieldString(t, mapResult(t, got), "result"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("heading", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "heading", "level": 2, "text": "Title"})
|
||||
assertNoErr(t, err)
|
||||
if want := "## Title\n"; fieldString(t, mapResult(t, got), "result") != want {
|
||||
t.Fatalf("got %q", fieldString(t, mapResult(t, got), "result"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("metadata with front matter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "metadata", "content": "---\ntitle: Hi\n---\nBody text"})
|
||||
assertNoErr(t, err)
|
||||
m := mapResult(t, got)
|
||||
meta := m["meta"].(map[string]string)
|
||||
if meta["title"] != "Hi" {
|
||||
t.Fatalf("meta = %v, want title=Hi", meta)
|
||||
}
|
||||
if got := m["body"].(string); got != "Body text" {
|
||||
t.Fatalf("body = %q, want Body text", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("metadata without front matter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, map[string]any{"operation": "metadata", "content": "just body"})
|
||||
assertNoErr(t, err)
|
||||
m := mapResult(t, got)
|
||||
if meta := m["meta"].(map[string]string); len(meta) != 0 {
|
||||
t.Fatalf("meta = %v, want empty", meta)
|
||||
}
|
||||
if got := m["body"].(string); got != "just body" {
|
||||
t.Fatalf("body = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMarkdown_Execute_Errors covers bad operation inputs.
|
||||
func TestMarkdown_Execute_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := markdown{}
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
target error
|
||||
}{
|
||||
{name: "unknown operation", args: map[string]any{"operation": "quote"}, target: ErrUnsupported},
|
||||
{name: "table without headers", args: map[string]any{"operation": "table", "rows": []any{}}, target: ErrInvalidArgument},
|
||||
{name: "list without items", args: map[string]any{"operation": "list"}, target: ErrInvalidArgument},
|
||||
{name: "heading level too low", args: map[string]any{"operation": "heading", "level": 0, "text": "x"}, target: ErrInvalidArgument},
|
||||
{name: "heading level too high", args: map[string]any{"operation": "heading", "level": 7, "text": "x"}, target: ErrInvalidArgument},
|
||||
{name: "heading without text", args: map[string]any{"operation": "heading", "level": 1}, target: ErrInvalidArgument},
|
||||
{name: "metadata without content", args: map[string]any{"operation": "metadata"}, target: ErrInvalidArgument},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := run(t, tool, tc.args)
|
||||
assertIs(t, err, tc.target)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdown_Schema verifies required operation.
|
||||
func TestMarkdown_Schema(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := markdown{}.Parameters()
|
||||
assertNoErr(t, err)
|
||||
reqs := p["required"].([]any)
|
||||
if len(reqs) != 1 || reqs[0] != "operation" {
|
||||
t.Fatalf("required = %v, want [operation]", reqs)
|
||||
}
|
||||
if !strings.Contains(p["properties"].(map[string]any)["operation"].(map[string]any)["enum"].([]any)[0].(string), "table") {
|
||||
t.Fatalf("enum should include table")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// random generates a cryptographically-random integer in [min,max] or a random
|
||||
// string of a given length drawn from a charset. Providing min/max selects the
|
||||
// integer mode; providing length selects the string mode.
|
||||
type random struct{}
|
||||
|
||||
const (
|
||||
// maxRandomLen bounds a generated string length.
|
||||
maxRandomLen = 1024
|
||||
// defaultCharset is the alphabet used when none is supplied.
|
||||
defaultCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
)
|
||||
|
||||
// Name returns the tool identifier.
|
||||
func (random) Name() string { return "random" }
|
||||
|
||||
// Description explains what the tool does for the model.
|
||||
func (random) Description() string {
|
||||
return "Generate a cryptographically-random integer in [min,max] or a random string of a given length."
|
||||
}
|
||||
|
||||
// Parameters returns the tool input JSON Schema.
|
||||
func (random) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"min": map[string]any{"type": "integer", "description": "inclusive lower bound for an integer"},
|
||||
"max": map[string]any{"type": "integer", "description": "inclusive upper bound for an integer"},
|
||||
"length": map[string]any{"type": "integer", "description": "length of the random string"},
|
||||
"charset": map[string]any{"type": "string", "description": "characters the string is drawn from"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute produces either an integer or a string depending on which arguments
|
||||
// are supplied.
|
||||
func (random) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := asMap(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasKey(m, "min") || hasKey(m, "max") {
|
||||
min, err := optInt(m, "min", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
max, err := optInt(m, "max", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := randInt(min, max)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"value": v}, nil
|
||||
}
|
||||
if hasKey(m, "length") {
|
||||
n, err := reqInt(m, "length")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
charset, err := optString(m, "charset", defaultCharset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := randString(n, charset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"value": s}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("basic: provide min/max for an integer or length for a string: %w", ErrInvalidArgument)
|
||||
}
|
||||
|
||||
// randInt returns a uniformly-distributed integer in [min,max] inclusive, using
|
||||
// crypto/rand so the result is not predictable from the process state.
|
||||
func randInt(min, max int) (int, error) {
|
||||
if max < min {
|
||||
return 0, fmt.Errorf("basic: max must be >= min: %w", ErrInvalidArgument)
|
||||
}
|
||||
if min == max {
|
||||
return min, nil
|
||||
}
|
||||
span := int64(max) - int64(min) + 1
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(span))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("basic: random source: %w", err)
|
||||
}
|
||||
return int(n.Int64()) + min, nil
|
||||
}
|
||||
|
||||
// randString returns a length-character string drawn uniformly from charset using
|
||||
// crypto/rand (no modulo bias).
|
||||
func randString(length int, charset string) (string, error) {
|
||||
if length < 1 || length > maxRandomLen {
|
||||
return "", fmt.Errorf("basic: length must be between 1 and %d: %w", maxRandomLen, ErrInvalidArgument)
|
||||
}
|
||||
cs := []rune(charset)
|
||||
if len(cs) == 0 {
|
||||
return "", fmt.Errorf("basic: charset must not be empty: %w", ErrInvalidArgument)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(length)
|
||||
span := big.NewInt(int64(len(cs)))
|
||||
for i := 0; i < length; i++ {
|
||||
n, err := rand.Int(rand.Reader, span)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("basic: random source: %w", err)
|
||||
}
|
||||
b.WriteRune(cs[n.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRandom_Execute_Normal covers integer range and string generation.
|
||||
func TestRandom_Execute_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := random{}
|
||||
|
||||
t.Run("integer in range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for i := 0; i < 200; i++ {
|
||||
got, err := run(t, r, map[string]any{"min": 5, "max": 9})
|
||||
assertNoErr(t, err)
|
||||
v := fieldInt(t, mapResult(t, got), "value")
|
||||
if v < 5 || v > 9 {
|
||||
t.Fatalf("value %d out of range [5,9]", v)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string length and charset", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, r, map[string]any{"length": 12, "charset": "ab"})
|
||||
assertNoErr(t, err)
|
||||
s := fieldString(t, mapResult(t, got), "value")
|
||||
if len([]rune(s)) != 12 {
|
||||
t.Fatalf("length = %d, want 12", len([]rune(s)))
|
||||
}
|
||||
if strings.Trim(s, "ab") != "" {
|
||||
t.Fatalf("value %q contains characters outside charset", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single value range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, r, map[string]any{"min": 7, "max": 7})
|
||||
assertNoErr(t, err)
|
||||
if v := fieldInt(t, mapResult(t, got), "value"); v != 7 {
|
||||
t.Fatalf("value = %d, want 7", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRandom_Execute_Errors covers invalid parameters.
|
||||
func TestRandom_Execute_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := random{}
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{name: "no args", args: map[string]any{}},
|
||||
{name: "inverted range", args: map[string]any{"min": 10, "max": 2}},
|
||||
{name: "min not integer", args: map[string]any{"min": "x", "max": 5}},
|
||||
{name: "zero length", args: map[string]any{"length": 0}},
|
||||
{name: "negative length", args: map[string]any{"length": -1}},
|
||||
{name: "length too big", args: map[string]any{"length": maxRandomLen + 1}},
|
||||
{name: "empty charset", args: map[string]any{"length": 4, "charset": ""}},
|
||||
{name: "non-string charset", args: map[string]any{"length": 4, "charset": 9}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := run(t, r, tc.args)
|
||||
assertIs(t, err, ErrInvalidArgument)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRandom_Schema verifies the JSON Schema contract.
|
||||
func TestRandom_Schema(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := random{}.Parameters()
|
||||
assertNoErr(t, err)
|
||||
props := p["properties"].(map[string]any)
|
||||
for _, field := range []string{"min", "max", "length"} {
|
||||
if props[field].(map[string]any)["type"] != "integer" {
|
||||
t.Fatalf("%s type should be integer", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// Tools returns the built-in basic tools in registration order: calculator,
|
||||
// datetime, random, text, json and markdown. Each element is a stateless
|
||||
// sdk.Tool value, safe for concurrent use.
|
||||
func Tools() []sdk.Tool {
|
||||
return []sdk.Tool{
|
||||
calculator{},
|
||||
datetime{},
|
||||
random{},
|
||||
text{},
|
||||
jsonTool{},
|
||||
markdown{},
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers every basic tool with reg. It returns an error wrapping
|
||||
// sdk.ErrNilRegistrar when reg is nil, and stops at the first failing tool,
|
||||
// wrapping the registry error so errors.Is can still reach it.
|
||||
func Register(reg *registry.Registry) error {
|
||||
if reg == nil {
|
||||
return fmt.Errorf("basic: register: %w", sdk.ErrNilRegistrar)
|
||||
}
|
||||
for _, t := range Tools() {
|
||||
if err := reg.RegisterTool(t); err != nil {
|
||||
return fmt.Errorf("basic: register %q: %w", t.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// TestTools verifies Tools returns six valid, uniquely-named sdk.Tool values.
|
||||
func TestTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
tools := Tools()
|
||||
if len(tools) != 6 {
|
||||
t.Fatalf("Tools() = %d tools, want 6", len(tools))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, tool := range tools {
|
||||
if tool.Name() == "" || tool.Description() == "" {
|
||||
t.Fatalf("tool %q missing Name or Description", tool.Name())
|
||||
}
|
||||
params, err := tool.Parameters()
|
||||
if err != nil {
|
||||
t.Fatalf("tool %q Parameters() error: %v", tool.Name(), err)
|
||||
}
|
||||
if params == nil {
|
||||
t.Fatalf("tool %q Parameters() returned nil", tool.Name())
|
||||
}
|
||||
if seen[tool.Name()] {
|
||||
t.Fatalf("duplicate tool name %q", tool.Name())
|
||||
}
|
||||
seen[tool.Name()] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_Success verifies every tool is registered as a KindTool capability.
|
||||
func TestRegister_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
if err := Register(reg); err != nil {
|
||||
t.Fatalf("Register() error: %v", err)
|
||||
}
|
||||
caps := reg.List(registry.KindTool)
|
||||
if len(caps) != 6 {
|
||||
t.Fatalf("List(KindTool) = %d, want 6", len(caps))
|
||||
}
|
||||
for _, name := range []string{"calculator", "datetime", "random", "text", "json", "markdown"} {
|
||||
cap, err := reg.Get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q) error: %v", name, err)
|
||||
}
|
||||
if cap.Kind != registry.KindTool {
|
||||
t.Fatalf("capability %q kind = %v, want tool", name, cap.Kind)
|
||||
}
|
||||
if _, ok := cap.Value.(sdk.Tool); !ok {
|
||||
t.Fatalf("capability %q not an sdk.Tool", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_NilRegistry verifies nil registry is rejected.
|
||||
func TestRegister_NilRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := Register(nil)
|
||||
assertIs(t, err, sdk.ErrNilRegistrar)
|
||||
}
|
||||
|
||||
// TestRegister_Duplicate verifies a second registration is rejected.
|
||||
func TestRegister_Duplicate(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
if err := Register(reg); err != nil {
|
||||
t.Fatalf("first Register() error: %v", err)
|
||||
}
|
||||
err := Register(reg)
|
||||
assertIs(t, err, registry.ErrDuplicate)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// text exposes common string transformations: case folding, trimming, length,
|
||||
// splitting, replacement and template placeholder substitution.
|
||||
type text struct{}
|
||||
|
||||
// Name returns the tool identifier.
|
||||
func (text) Name() string { return "text" }
|
||||
|
||||
// Description explains what the tool does for the model.
|
||||
func (text) Description() string {
|
||||
return "Transform or measure text: upper, lower, title, trim, length, split, replace and template placeholder substitution."
|
||||
}
|
||||
|
||||
// Parameters returns the tool input JSON Schema. The operation-specific inputs
|
||||
// (delimiter, old/new for replace, values for template) are validated by Execute
|
||||
// because they only apply to their operation.
|
||||
func (text) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"operation": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []any{"upper", "lower", "title", "trim", "length", "split", "replace", "template"},
|
||||
"description": "the transformation to apply",
|
||||
},
|
||||
"input": map[string]any{"type": "string", "description": "the input string"},
|
||||
"delimiter": map[string]any{"type": "string", "description": "split delimiter"},
|
||||
"old": map[string]any{"type": "string", "description": "substring to replace"},
|
||||
"new": map[string]any{"type": "string", "description": "replacement for old"},
|
||||
"values": map[string]any{"type": "object", "description": "template placeholder substitutions"},
|
||||
},
|
||||
"required": []any{"operation", "input"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute applies the requested operation.
|
||||
func (text) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := asMap(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op, err := reqString(m, "operation")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in, err := reqString(m, "input")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(op)) {
|
||||
case "upper":
|
||||
return map[string]any{"result": strings.ToUpper(in)}, nil
|
||||
case "lower":
|
||||
return map[string]any{"result": strings.ToLower(in)}, nil
|
||||
case "title":
|
||||
return map[string]any{"result": title(in)}, nil
|
||||
case "trim":
|
||||
return map[string]any{"result": strings.TrimSpace(in)}, nil
|
||||
case "length":
|
||||
return map[string]any{"result": utf8.RuneCountInString(in)}, nil
|
||||
case "split":
|
||||
delim, err := reqString(m, "delimiter")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := strings.Split(in, delim)
|
||||
out := make([]any, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = p
|
||||
}
|
||||
return map[string]any{"result": out}, nil
|
||||
case "replace":
|
||||
old, err := reqString(m, "old")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repl, err := reqString(m, "new")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"result": strings.ReplaceAll(in, old, repl)}, nil
|
||||
case "template":
|
||||
vals, err := reqStringMap(m, "values")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := renderTemplate(in, vals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"result": out}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("basic: unknown text operation %q: %w", op, ErrUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
// title returns the title case of s: the first letter of each whitespace-delimited
|
||||
// word upper-cased and the rest lower-cased, preserving the original layout.
|
||||
func title(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
atStart := true
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) {
|
||||
if atStart {
|
||||
b.WriteRune(unicode.ToUpper(r))
|
||||
} else {
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
atStart = false
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
if unicode.IsSpace(r) {
|
||||
atStart = true
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderTemplate substitutes {key} placeholders in tpl using vals, leaving any
|
||||
// placeholder whose key is absent unchanged. It scans once so a value that itself
|
||||
// contains a placeholder is not re-substituted.
|
||||
func renderTemplate(tpl string, vals map[string]string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(len(tpl))
|
||||
for i := 0; i < len(tpl); {
|
||||
open := strings.IndexByte(tpl[i:], '{')
|
||||
if open < 0 {
|
||||
b.WriteString(tpl[i:])
|
||||
break
|
||||
}
|
||||
b.WriteString(tpl[i : i+open])
|
||||
close := strings.IndexByte(tpl[i+open:], '}')
|
||||
if close < 0 {
|
||||
return "", fmt.Errorf("basic: unbalanced placeholder in template: %w", ErrInvalidArgument)
|
||||
}
|
||||
start := i + open + 1
|
||||
end := i + open + close
|
||||
key := tpl[start:end]
|
||||
if val, ok := vals[key]; ok {
|
||||
b.WriteString(val)
|
||||
} else {
|
||||
b.WriteString(tpl[i+open : end+1])
|
||||
}
|
||||
i = end + 1
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestText_Execute_Normal covers each supported operation.
|
||||
func TestText_Execute_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := text{}
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
check func(t *testing.T, m map[string]any)
|
||||
}{
|
||||
{name: "upper", args: map[string]any{"operation": "upper", "input": "abc"}, check: func(t *testing.T, m map[string]any) {
|
||||
if got := fieldString(t, m, "result"); got != "ABC" {
|
||||
t.Fatalf("result = %q, want ABC", got)
|
||||
}
|
||||
}},
|
||||
{name: "lower", args: map[string]any{"operation": "lower", "input": "ABC"}, check: func(t *testing.T, m map[string]any) {
|
||||
if got := fieldString(t, m, "result"); got != "abc" {
|
||||
t.Fatalf("result = %q, want abc", got)
|
||||
}
|
||||
}},
|
||||
{name: "trim", args: map[string]any{"operation": "trim", "input": " x "}, check: func(t *testing.T, m map[string]any) {
|
||||
if got := fieldString(t, m, "result"); got != "x" {
|
||||
t.Fatalf("result = %q, want x", got)
|
||||
}
|
||||
}},
|
||||
{name: "title", args: map[string]any{"operation": "title", "input": "hello world"}, check: func(t *testing.T, m map[string]any) {
|
||||
if got := fieldString(t, m, "result"); got != "Hello World" {
|
||||
t.Fatalf("result = %q, want Hello World", got)
|
||||
}
|
||||
}},
|
||||
{name: "length", args: map[string]any{"operation": "length", "input": "héllo"}, check: func(t *testing.T, m map[string]any) {
|
||||
if got := fieldInt(t, m, "result"); got != 5 {
|
||||
t.Fatalf("result = %d, want 5 runes", got)
|
||||
}
|
||||
}},
|
||||
{name: "split", args: map[string]any{"operation": "split", "input": "a,b,c", "delimiter": ","}, check: func(t *testing.T, m map[string]any) {
|
||||
got := m["result"].([]any)
|
||||
if !reflect.DeepEqual(got, []any{"a", "b", "c"}) {
|
||||
t.Fatalf("result = %v, want [a b c]", got)
|
||||
}
|
||||
}},
|
||||
{name: "replace", args: map[string]any{"operation": "replace", "input": "a-b-a", "old": "a", "new": "x"}, check: func(t *testing.T, m map[string]any) {
|
||||
if got := fieldString(t, m, "result"); got != "x-b-x" {
|
||||
t.Fatalf("result = %q, want x-b-x", got)
|
||||
}
|
||||
}},
|
||||
{name: "template", args: map[string]any{"operation": "template", "input": "Hello {name}!", "values": map[string]any{"name": "Ada"}}, check: func(t *testing.T, m map[string]any) {
|
||||
if got := fieldString(t, m, "result"); got != "Hello Ada!" {
|
||||
t.Fatalf("result = %q, want Hello Ada!", got)
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := run(t, tool, tc.args)
|
||||
assertNoErr(t, err)
|
||||
tc.check(t, mapResult(t, got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestText_Execute_Errors covers unknown operations and missing per-op inputs.
|
||||
func TestText_Execute_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := text{}
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
target error
|
||||
}{
|
||||
{name: "unknown operation", args: map[string]any{"operation": "frobnicate", "input": "x"}, target: ErrUnsupported},
|
||||
{name: "missing input", args: map[string]any{"operation": "upper"}, target: ErrInvalidArgument},
|
||||
{name: "non-string input", args: map[string]any{"operation": "upper", "input": 9}, target: ErrInvalidArgument},
|
||||
{name: "split without delimiter", args: map[string]any{"operation": "split", "input": "a"}, target: ErrInvalidArgument},
|
||||
{name: "replace without old", args: map[string]any{"operation": "replace", "input": "a", "new": "b"}, target: ErrInvalidArgument},
|
||||
{name: "replace without new", args: map[string]any{"operation": "replace", "input": "a", "old": "a"}, target: ErrInvalidArgument},
|
||||
{name: "template without values", args: map[string]any{"operation": "template", "input": "{x}"}, target: ErrInvalidArgument},
|
||||
{name: "unbalanced placeholder", args: map[string]any{"operation": "template", "input": "{x", "values": map[string]any{}}, target: ErrInvalidArgument},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := run(t, tool, tc.args)
|
||||
assertIs(t, err, tc.target)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestText_Schema verifies required fields.
|
||||
func TestText_Schema(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := text{}.Parameters()
|
||||
assertNoErr(t, err)
|
||||
reqs := p["required"].([]any)
|
||||
if len(reqs) != 2 || reqs[0] != "operation" || reqs[1] != "input" {
|
||||
t.Fatalf("required = %v, want [operation input]", reqs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package basic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the basic tools. Use errors.Is to test for them.
|
||||
var (
|
||||
// ErrInvalidArgument is returned when a tool is given a missing required
|
||||
// argument or an argument of the wrong type.
|
||||
ErrInvalidArgument = errors.New("basic: invalid argument")
|
||||
// ErrUnsupported is returned when an operation is not in the tool allowlist.
|
||||
ErrUnsupported = errors.New("basic: unsupported operation")
|
||||
// ErrEvaluation is returned when a calculator expression cannot be parsed or
|
||||
// evaluated.
|
||||
ErrEvaluation = errors.New("basic: evaluation failed")
|
||||
)
|
||||
|
||||
// asMap asserts args is a nil value or a map[string]any — the shape the
|
||||
// dispatcher passes when a tool's Parameters declares an object schema.
|
||||
func asMap(args any) (map[string]any, error) {
|
||||
if args == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: arguments must be an object: %w", ErrInvalidArgument)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// hasKey reports whether m contains key.
|
||||
func hasKey(m map[string]any, key string) bool {
|
||||
_, ok := m[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// reqString returns the string argument under key, erroring when it is absent or
|
||||
// not a string.
|
||||
func reqString(m map[string]any, key string) (string, error) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("basic: missing required argument %q: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("basic: argument %q must be a string: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optString returns the string argument under key, or def when absent. A present
|
||||
// non-string is an error.
|
||||
func optString(m map[string]any, key, def string) (string, error) {
|
||||
if !hasKey(m, key) {
|
||||
return def, nil
|
||||
}
|
||||
return reqString(m, key)
|
||||
}
|
||||
|
||||
// reqInt returns the integer argument under key, erroring when it is absent, not
|
||||
// a number, or not integral.
|
||||
func reqInt(m map[string]any, key string) (int, error) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("basic: missing required argument %q: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
n, err := asInt(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("basic: argument %q: %w", key, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// optInt returns the integer argument under key, or def when absent.
|
||||
func optInt(m map[string]any, key string, def int) (int, error) {
|
||||
if !hasKey(m, key) {
|
||||
return def, nil
|
||||
}
|
||||
return reqInt(m, key)
|
||||
}
|
||||
|
||||
// optBool returns the boolean argument under key, or def when absent.
|
||||
func optBool(m map[string]any, key string, def bool) (bool, error) {
|
||||
if !hasKey(m, key) {
|
||||
return def, nil
|
||||
}
|
||||
v, ok := m[key].(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("basic: argument %q must be a boolean: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// reqStringMap returns the object argument under key coerced to map[string]string,
|
||||
// erroring when a value is not a string.
|
||||
func reqStringMap(m map[string]any, key string) (map[string]string, error) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: missing required argument %q: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
raw, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q must be an object: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for k, val := range raw {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: value %q must be a string: %w", k, ErrInvalidArgument)
|
||||
}
|
||||
out[k] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// strSliceArg returns the array-of-strings argument under key.
|
||||
func strSliceArg(m map[string]any, key string) ([]string, error) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: missing required argument %q: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q must be an array: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
out := make([]string, len(raw))
|
||||
for i, item := range raw {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q item %d must be a string: %w", key, i, ErrInvalidArgument)
|
||||
}
|
||||
out[i] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// strSliceSliceArg returns the array-of-arrays-of-strings argument under key.
|
||||
func strSliceSliceArg(m map[string]any, key string) ([][]string, error) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: missing required argument %q: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q must be an array: %w", key, ErrInvalidArgument)
|
||||
}
|
||||
out := make([][]string, len(raw))
|
||||
for i, item := range raw {
|
||||
row, ok := item.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q item %d must be an array: %w", key, i, ErrInvalidArgument)
|
||||
}
|
||||
cells := make([]string, len(row))
|
||||
for j, c := range row {
|
||||
s, ok := c.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("basic: argument %q item %d cell %d must be a string: %w", key, i, j, ErrInvalidArgument)
|
||||
}
|
||||
cells[j] = s
|
||||
}
|
||||
out[i] = cells
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// asInt converts a numeric-typed value (the types the dispatcher decodes JSON
|
||||
// into) to an int, rejecting non-integral floats.
|
||||
func asInt(v any) (int, error) {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x, nil
|
||||
case int64:
|
||||
return int(x), nil
|
||||
case float64:
|
||||
if x != math.Trunc(x) || x < math.MinInt64 || x > math.MaxInt64 {
|
||||
return 0, fmt.Errorf("must be an integer: %w", ErrInvalidArgument)
|
||||
}
|
||||
return int(x), nil
|
||||
case json.Number:
|
||||
n, err := x.Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be an integer: %w", ErrInvalidArgument)
|
||||
}
|
||||
return int(n), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("must be a number: %w", ErrInvalidArgument)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user