From 08223c06a59c3b841d72286ef120494e9cf10c65 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 06:26:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools/basic):=20=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=B7=A5=E5=85=B7=EF=BC=88calculator(?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E5=AE=89=E5=85=A8)/datetime/random/text/json?= =?UTF-8?q?/markdown=EF=BC=89+=20=E6=89=B9=E9=87=8F=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/tools/basic/calculator.go | 317 +++++++++++++++++++++++++ plugins/tools/basic/calculator_test.go | 113 +++++++++ plugins/tools/basic/context_test.go | 25 ++ plugins/tools/basic/datetime.go | 77 ++++++ plugins/tools/basic/datetime_test.go | 99 ++++++++ plugins/tools/basic/doc.go | 9 + plugins/tools/basic/helpers_test.go | 104 ++++++++ plugins/tools/basic/json.go | 95 ++++++++ plugins/tools/basic/json_test.go | 119 ++++++++++ plugins/tools/basic/log.go | 17 ++ plugins/tools/basic/markdown.go | 193 +++++++++++++++ plugins/tools/basic/markdown_test.go | 121 ++++++++++ plugins/tools/basic/random.go | 125 ++++++++++ plugins/tools/basic/random_test.go | 86 +++++++ plugins/tools/basic/register.go | 37 +++ plugins/tools/basic/register_test.go | 77 ++++++ plugins/tools/basic/text.go | 161 +++++++++++++ plugins/tools/basic/text_test.go | 107 +++++++++ plugins/tools/basic/util.go | 192 +++++++++++++++ 19 files changed, 2074 insertions(+) create mode 100644 plugins/tools/basic/calculator.go create mode 100644 plugins/tools/basic/calculator_test.go create mode 100644 plugins/tools/basic/context_test.go create mode 100644 plugins/tools/basic/datetime.go create mode 100644 plugins/tools/basic/datetime_test.go create mode 100644 plugins/tools/basic/doc.go create mode 100644 plugins/tools/basic/helpers_test.go create mode 100644 plugins/tools/basic/json.go create mode 100644 plugins/tools/basic/json_test.go create mode 100644 plugins/tools/basic/log.go create mode 100644 plugins/tools/basic/markdown.go create mode 100644 plugins/tools/basic/markdown_test.go create mode 100644 plugins/tools/basic/random.go create mode 100644 plugins/tools/basic/random_test.go create mode 100644 plugins/tools/basic/register.go create mode 100644 plugins/tools/basic/register_test.go create mode 100644 plugins/tools/basic/text.go create mode 100644 plugins/tools/basic/text_test.go create mode 100644 plugins/tools/basic/util.go diff --git a/plugins/tools/basic/calculator.go b/plugins/tools/basic/calculator.go new file mode 100644 index 0000000..33f7f95 --- /dev/null +++ b/plugins/tools/basic/calculator.go @@ -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) + } +} diff --git a/plugins/tools/basic/calculator_test.go b/plugins/tools/basic/calculator_test.go new file mode 100644 index 0000000..2f39d5f --- /dev/null +++ b/plugins/tools/basic/calculator_test.go @@ -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) + } +} diff --git a/plugins/tools/basic/context_test.go b/plugins/tools/basic/context_test.go new file mode 100644 index 0000000..bf9fd13 --- /dev/null +++ b/plugins/tools/basic/context_test.go @@ -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) + } + }) + } +} diff --git a/plugins/tools/basic/datetime.go b/plugins/tools/basic/datetime.go new file mode 100644 index 0000000..4b641b2 --- /dev/null +++ b/plugins/tools/basic/datetime.go @@ -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 +} diff --git a/plugins/tools/basic/datetime_test.go b/plugins/tools/basic/datetime_test.go new file mode 100644 index 0000000..a93b0d8 --- /dev/null +++ b/plugins/tools/basic/datetime_test.go @@ -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) + } + } +} diff --git a/plugins/tools/basic/doc.go b/plugins/tools/basic/doc.go new file mode 100644 index 0000000..c8ac610 --- /dev/null +++ b/plugins/tools/basic/doc.go @@ -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 diff --git a/plugins/tools/basic/helpers_test.go b/plugins/tools/basic/helpers_test.go new file mode 100644 index 0000000..eec3b5a --- /dev/null +++ b/plugins/tools/basic/helpers_test.go @@ -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) + } +} diff --git a/plugins/tools/basic/json.go b/plugins/tools/basic/json.go new file mode 100644 index 0000000..b82f006 --- /dev/null +++ b/plugins/tools/basic/json.go @@ -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) + } +} diff --git a/plugins/tools/basic/json_test.go b/plugins/tools/basic/json_test.go new file mode 100644 index 0000000..ef021a7 --- /dev/null +++ b/plugins/tools/basic/json_test.go @@ -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") + } +} diff --git a/plugins/tools/basic/log.go b/plugins/tools/basic/log.go new file mode 100644 index 0000000..74f0f77 --- /dev/null +++ b/plugins/tools/basic/log.go @@ -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") +} diff --git a/plugins/tools/basic/markdown.go b/plugins/tools/basic/markdown.go new file mode 100644 index 0000000..1fe9735 --- /dev/null +++ b/plugins/tools/basic/markdown.go @@ -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 +} diff --git a/plugins/tools/basic/markdown_test.go b/plugins/tools/basic/markdown_test.go new file mode 100644 index 0000000..33258d6 --- /dev/null +++ b/plugins/tools/basic/markdown_test.go @@ -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") + } +} diff --git a/plugins/tools/basic/random.go b/plugins/tools/basic/random.go new file mode 100644 index 0000000..52f2bfa --- /dev/null +++ b/plugins/tools/basic/random.go @@ -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 +} diff --git a/plugins/tools/basic/random_test.go b/plugins/tools/basic/random_test.go new file mode 100644 index 0000000..03b0174 --- /dev/null +++ b/plugins/tools/basic/random_test.go @@ -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) + } + } +} diff --git a/plugins/tools/basic/register.go b/plugins/tools/basic/register.go new file mode 100644 index 0000000..de7fcca --- /dev/null +++ b/plugins/tools/basic/register.go @@ -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 +} diff --git a/plugins/tools/basic/register_test.go b/plugins/tools/basic/register_test.go new file mode 100644 index 0000000..71f1015 --- /dev/null +++ b/plugins/tools/basic/register_test.go @@ -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) +} diff --git a/plugins/tools/basic/text.go b/plugins/tools/basic/text.go new file mode 100644 index 0000000..cb66445 --- /dev/null +++ b/plugins/tools/basic/text.go @@ -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 +} diff --git a/plugins/tools/basic/text_test.go b/plugins/tools/basic/text_test.go new file mode 100644 index 0000000..6d63eb7 --- /dev/null +++ b/plugins/tools/basic/text_test.go @@ -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) + } +} diff --git a/plugins/tools/basic/util.go b/plugins/tools/basic/util.go new file mode 100644 index 0000000..4515396 --- /dev/null +++ b/plugins/tools/basic/util.go @@ -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) + } +}