feat(tools/basic): 内置基础工具(calculator(注入安全)/datetime/random/text/json/markdown)+ 批量注册

This commit is contained in:
Aska committed 2026-09-06 06:26:59 +08:00
1 parent b3c61aa1ff
commit 08223c06a5
19 files changed
+2074

No files matched your search

+317
View File
@@ -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)
}
}
+113
View File
@@ -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)
}
}
+25
View File
@@ -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)
}
})
}
}
+77
View File
@@ -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
}
+99
View File
@@ -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)
}
}
}
+9
View File
@@ -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
+104
View File
@@ -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)
}
}
+95
View File
@@ -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)
}
}
+119
View File
@@ -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")
}
}
+17
View File
@@ -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")
}
+193
View File
@@ -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
}
+121
View File
@@ -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")
}
}
+125
View File
@@ -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
}
+86
View File
@@ -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)
}
}
}
+37
View File
@@ -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
}
+77
View File
@@ -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)
}
+161
View File
@@ -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
}
+107
View File
@@ -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)
}
}
+192
View File
@@ -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)
}
}