Files
steve dc28b63ad8
executus CI / test (push) Successful in 36s
P1 (part 1): move skilltools core -> tool/ (clean, verbatim)
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>
2026-06-26 19:31:47 -04:00

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
}