P1 (part 1): move skilltools core -> tool/ (clean, verbatim)
executus CI / test (push) Successful in 36s
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>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user