dc28b63ad8
executus CI / test (push) Successful in 36s
The tool registry core (registry, permission model, Invocation, gated-tool wrapper, ssrf guard, hmac, encryption, argcoerce, helpers, rootrun, session_tools, webhook_rate_limit) had zero mort coupling — it imports only majordomo/llm + x/crypto/hkdf — so it moves verbatim with a package rename (skilltools -> tool). All same-package tests came along and pass; the SSRF, gated-wrapper, encryption and output-pattern invariants are re-anchored here. majordomo re-enters the module graph (now pinned to the latest, incl. the front-loaded-output fix). model/ + llmmeta + structured follow next. Docs: CLAUDE.md now requires README/examples to stay in sync with changes in the same commit; CI skips docs/example-only pushes via paths-ignore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
4.0 KiB
Go
162 lines
4.0 KiB
Go
package tool
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// unmarshalArgsLenient decodes the raw JSON arguments the model supplied
|
|
// into the tool's typed Args struct, tolerating the classic LLM tool-call
|
|
// bug of emitting numbers and booleans as strings ("3" where the schema
|
|
// said integer, "true" where it said boolean).
|
|
//
|
|
// Why mort-side (vs relying on the library): legacy gollm's Define performed
|
|
// this coercion internally (tool_coerce.go), and several years of tool
|
|
// traffic depend on the tolerance. majordomo's DefineTool decodes
|
|
// strictly by design, so the gated wrappers re-create the leniency here
|
|
// — the strict path is tried first and coercion only runs on failure,
|
|
// which makes the happy path zero-cost.
|
|
func unmarshalArgsLenient(raw json.RawMessage, target any) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
strictErr := json.Unmarshal(raw, target)
|
|
if strictErr == nil {
|
|
return nil
|
|
}
|
|
coerced, err := coerceArgsToType(raw, reflect.TypeOf(target).Elem())
|
|
if err != nil {
|
|
// Malformed JSON: surface the original strict error, which
|
|
// names the real problem.
|
|
return strictErr
|
|
}
|
|
if err := json.Unmarshal(coerced, target); err != nil {
|
|
return strictErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// coerceArgsToType reparses argsJSON with leniency: where the target
|
|
// struct expects a numeric or boolean field but the JSON value is a
|
|
// string, it converts the string to the target kind. Recurses into
|
|
// nested structs, slices, maps, and pointer fields. Returns a freshly
|
|
// marshaled JSON byte slice that unmarshals strictly into the target.
|
|
func coerceArgsToType(argsJSON []byte, target reflect.Type) ([]byte, error) {
|
|
var raw any
|
|
if err := json.Unmarshal(argsJSON, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
raw = coerceValue(raw, target)
|
|
return json.Marshal(raw)
|
|
}
|
|
|
|
func coerceValue(v any, t reflect.Type) any {
|
|
if t == nil {
|
|
return v
|
|
}
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
|
|
switch t.Kind() {
|
|
case reflect.Struct:
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
return v
|
|
}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
if !f.IsExported() {
|
|
continue
|
|
}
|
|
name := jsonFieldName(f)
|
|
if name == "-" {
|
|
continue
|
|
}
|
|
if val, present := m[name]; present {
|
|
m[name] = coerceValue(val, f.Type)
|
|
}
|
|
}
|
|
return m
|
|
|
|
case reflect.Slice, reflect.Array:
|
|
arr, ok := v.([]any)
|
|
if !ok {
|
|
return v
|
|
}
|
|
elemType := t.Elem()
|
|
for i := range arr {
|
|
arr[i] = coerceValue(arr[i], elemType)
|
|
}
|
|
return arr
|
|
|
|
case reflect.Map:
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
return v
|
|
}
|
|
valType := t.Elem()
|
|
for k := range m {
|
|
m[k] = coerceValue(m[k], valType)
|
|
}
|
|
return m
|
|
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
if s, ok := v.(string); ok {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimPrefix(s, "+")
|
|
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
return n
|
|
}
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
return int64(f)
|
|
}
|
|
}
|
|
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
if s, ok := v.(string); ok {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimPrefix(s, "+")
|
|
if n, err := strconv.ParseUint(s, 10, 64); err == nil {
|
|
return n
|
|
}
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 {
|
|
return uint64(f)
|
|
}
|
|
}
|
|
|
|
case reflect.Float32, reflect.Float64:
|
|
if s, ok := v.(string); ok {
|
|
s = strings.TrimSpace(s)
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
return f
|
|
}
|
|
}
|
|
|
|
case reflect.Bool:
|
|
if s, ok := v.(string); ok {
|
|
if b, err := strconv.ParseBool(strings.TrimSpace(s)); err == nil {
|
|
return b
|
|
}
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// jsonFieldName returns the effective JSON key for a struct field:
|
|
// the json tag's name part when present, the Go field name otherwise,
|
|
// or "-" when the field is excluded.
|
|
func jsonFieldName(f reflect.StructField) string {
|
|
tag, ok := f.Tag.Lookup("json")
|
|
if !ok {
|
|
return f.Name
|
|
}
|
|
name, _, _ := strings.Cut(tag, ",")
|
|
if name == "" {
|
|
return f.Name
|
|
}
|
|
return name
|
|
}
|