Merlin / v3-ungated /native /stepsafety /normalization.go
hasandmrkrn's picture
Add Merlin V3 ungated native evaluation release
9e8e328 verified
Raw History Blame Contribute Delete
5.11 kB
package stepsafety
import (
"encoding/json"
"io"
"regexp"
"strings"
)
var schemaHeader = regexp.MustCompile(`(?m)^([A-Za-z_][A-Za-z0-9_.:/-]*):[ \t]+`)
// candidateFields matches the saved joint-CE input view. Only API binding names
// and JSON representation change; executable strings, argument keys, request and
// observation text remain intact. Never modify the hook's shared maps in place.
func candidateFields(input Input) ([4]string, error) {
var fields [4]string
schema, err := candidateSchema(input.AvailableToolSchemas)
if err != nil {
return fields, err
}
definitions := schemaDefinitions(schema)
spellings := map[string]map[string]bool{}
for _, d := range definitions {
name := d["name"].(string)
key := lowerASCII(name)
if spellings[key] == nil {
spellings[key] = map[string]bool{}
}
spellings[key][name] = true
}
name := func(s string) string {
if len(spellings[lowerASCII(s)]) > 1 {
return s
}
return lowerASCII(s)
}
for _, d := range definitions {
d["name"] = name(d["name"].(string))
}
var schemaText string
if schema != nil {
if text, ok := schema.(string); ok {
schemaText = text
} else {
schemaText, err = compactSortedJSON(schema)
if err != nil {
return fields, err
}
}
}
history := input.InteractionHistory
if strings.TrimSpace(history) == "" {
history = "[]"
}
h, err := decodeCandidateJSON(history)
if err != nil {
return fields, backendError(ErrorInvalidHistory, nil)
}
events, ok := h.([]any)
if !ok || len(events) > maxHistoryEntries {
return fields, backendError(ErrorInvalidHistory, nil)
}
for _, event := range events {
e, ok := event.(map[string]any)
if !ok {
return fields, backendError(ErrorInvalidHistory, nil)
}
t, ok := e["tool"].(string)
if !ok || t == "" {
return fields, backendError(ErrorInvalidHistory, nil)
}
e["tool"] = name(t)
}
history, err = compactSortedJSON(events)
if err != nil {
return fields, err
}
args := input.ToolArguments
// These exact interfaces use title/description as call-display metadata.
// A TaskCreate description, WebFetch prompt, or arbitrary API payload is data.
switch input.ToolName {
case "Bash", "exec_command", "functions.exec_command", "mcp__workspace__bash", "mcp__cua_repl__js":
if values, ok := args.(map[string]any); ok {
copy := make(map[string]any, len(values))
for k, v := range values {
if k != "title" && k != "description" {
copy[k] = v
}
}
args = copy
}
}
arguments, err := compactSortedJSON(args)
if err != nil {
return fields, err
}
return [4]string{input.UserRequest, history, strings.TrimSpace("[TOOL_NAME]\n" + name(input.ToolName) + "\n[ARGUMENTS]\n" + arguments), schemaText}, nil
}
func lowerASCII(s string) string {
return strings.Map(func(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
return r
}, s)
}
func decodeCandidateJSON(text string) (any, error) {
d := json.NewDecoder(strings.NewReader(text))
d.UseNumber()
var v any
if err := d.Decode(&v); err != nil {
return nil, err
}
var extra any
if err := d.Decode(&extra); err != io.EOF {
return nil, backendError(ErrorUnsupportedText, nil)
}
return v, nil
}
func candidateSchema(value any) (any, error) {
if value == nil {
return nil, nil
}
if text, ok := value.(string); ok {
if strings.TrimSpace(text) == "" {
return nil, nil
}
if parsed, err := decodeCandidateJSON(text); err == nil {
switch parsed.(type) {
case []any, map[string]any:
return parsed, nil
}
}
matches := schemaHeader.FindAllStringSubmatchIndex(text, -1)
if len(matches) > 0 && strings.TrimSpace(text[:matches[0][0]]) == "" {
var definitions []any
for i, m := range matches {
end := len(text)
if i+1 < len(matches) {
end = matches[i+1][0]
}
definitions = append(definitions, map[string]any{"name": text[m[2]:m[3]], "description": strings.TrimSpace(text[m[1]:end])})
}
return definitions, nil
}
// Research also accepted Python literal containers. Hooks supply JSON;
// abstain on such unsupported containers instead of guessing a conversion.
if strings.HasPrefix(strings.TrimSpace(text), "[") || strings.HasPrefix(strings.TrimSpace(text), "{") {
return nil, backendError(ErrorUnsupportedText, nil)
}
return text, nil
}
text, err := compactSortedJSON(value)
if err != nil {
return nil, err
}
return decodeCandidateJSON(text)
}
func schemaDefinitions(value any) []map[string]any {
switch v := value.(type) {
case []any:
var result []map[string]any
for _, item := range v {
result = append(result, schemaDefinitions(item)...)
}
return result
case map[string]any:
if f, ok := v["function"].(map[string]any); ok {
return schemaDefinitions(f)
}
if t, ok := v["tools"].([]any); ok {
return schemaDefinitions(t)
}
_, named := v["name"].(string)
_, parameters := v["parameters"]
_, inputSchema := v["input_schema"]
_, description := v["description"]
if named && (parameters || inputSchema || description || len(v) == 1) {
return []map[string]any{v}
}
}
return nil
}