iop/apps/edge/internal/openai/workspace_tool_binding.go

648 lines
20 KiB
Go

package openai
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"reflect"
"strings"
"iop/packages/go/config"
)
// workspaceOperationKind enumerates the canonical workspace operations the
// binding compiler can encode. The Edge never executes these; it only produces
// deterministic, caller-executed payloads from the preset-declared contract.
type workspaceOperationKind string
const (
opKindPrepare workspaceOperationKind = "prepare"
opKindRead workspaceOperationKind = "read"
opKindWrite workspaceOperationKind = "write"
opKindDelete workspaceOperationKind = "delete"
)
// canonicalOperationOrder is the deterministic order in which an alternative's
// operations are compiled and fingerprinted. It never depends on Go map
// iteration order.
var canonicalOperationOrder = []workspaceOperationKind{opKindPrepare, opKindRead, opKindWrite, opKindDelete}
// workspaceBindingMode selects how a compiled operation maps tool arguments.
//
// structured: the actual tool exposes the workspace fields by name; the codec
// maps the configured argument fields directly and preserves typed values.
//
// command: the actual tool takes a synthesized command; the codec builds a
// deterministic, shell-safe command from a fixed argv template.
type workspaceBindingMode string
const (
modeStructured workspaceBindingMode = "structured"
modeCommand workspaceBindingMode = "command"
)
// workspaceToolSchema is the normalized view of one decoded tool definition. It
// accepts OpenAI Chat function wrappers, flat OpenAI parameters, and Anthropic
// input_schema shapes and exposes a single JSON Schema object for matching.
type workspaceToolSchema struct {
name string
description string
// schema is the full JSON Schema object (function.parameters / parameters /
// input_schema). It is matched against the configured schema_matcher.
schema map[string]any
// properties is the resolved property set (oneOf/anyOf/allOf aware) used to
// validate that mapped argument fields are actually declared by the tool.
properties map[string]any
}
// workspaceOperationBinding is the immutable compiled mapping for one canonical
// operation of a selected alternative.
type workspaceOperationBinding struct {
op workspaceOperationKind
toolName string
mode workspaceBindingMode
// Structured-mode actual argument field names (dot paths permitted).
pathField string
contentField string
modeField string
// Command-mode encoding.
commandField string
argvTemplate []string
// Immutable copies of the configured contract for this operation.
schemaMatcher map[string]any
argumentMap map[string]any
resultMatcher map[string]any
createsParents bool
// normalizedSchema is the actual tool schema this operation bound to.
normalizedSchema *workspaceToolSchema
}
// workspaceBinding is the immutable, fingerprinted selection of exactly one
// complete configured alternative. It carries every operation mapping and the
// parent-creation capability, and the Edge never mutates it after selection.
type workspaceBinding struct {
alternativeName string
operations map[workspaceOperationKind]*workspaceOperationBinding
// fingerprint is a sha256 of the canonical selected configuration plus the
// normalized actual schemas. It correlates results back to this binding.
fingerprint string
}
// compileWorkspaceBinding selects the first configured alternative whose every
// declared operation matches an actual decoded tool by exact tool name and
// recursive schema matcher. It never infers workspace roles from tool-name
// substrings and never inspects the workspace filesystem.
//
// It returns an immutable, fully-mapped binding, or nil with an error that
// explains why no complete alternative matched.
func compileWorkspaceBinding(alternatives []config.ExecutionWorkspaceToolAlternative, tools any) (*workspaceBinding, error) {
if len(alternatives) == 0 {
return nil, fmt.Errorf("no configured workspace tool alternatives")
}
schemasByName, err := normalizeToolSchemas(tools)
if err != nil {
return nil, err
}
var lastErr error
for _, alt := range alternatives {
binding, err := bindAlternative(alt, schemasByName)
if err != nil {
lastErr = err
continue
}
return binding, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no workspace tool alternative matched the provided tools")
}
return nil, lastErr
}
// normalizeToolSchemas normalizes every decoded tool definition into a schema
// view keyed by its exact tool name. Tools without a name are ignored; the
// first definition wins on duplicate names. It handles OpenAI Chat nested
// function wrappers, flat OpenAI parameters, and Anthropic input_schema shapes.
func normalizeToolSchemas(tools any) (map[string]*workspaceToolSchema, error) {
var entries []any
switch typed := tools.(type) {
case []any:
entries = typed
case []anthropicTool:
entries = make([]any, len(typed))
for i, tool := range typed {
entries[i] = tool
}
default:
return nil, fmt.Errorf("unsupported workspace tool slice type %T", tools)
}
out := make(map[string]*workspaceToolSchema, len(entries))
for _, rawTool := range entries {
schema := extractToolSchema(rawTool)
if schema == nil {
continue
}
if _, exists := out[schema.name]; exists {
continue
}
out[schema.name] = schema
}
return out, nil
}
// extractToolSchema pulls the normalized schema from a single tool entry. It
// recognizes the actual OpenAI Chat function wrapper
// ({type:"function",function:{name,description,parameters}}), the flat OpenAI
// shape ({name,parameters}), and the Anthropic shape ({name,input_schema}).
func extractToolSchema(rawTool any) *workspaceToolSchema {
switch tool := rawTool.(type) {
case map[string]any:
return extractMappedToolSchema(tool)
case anthropicTool:
return extractAnthropicToolSchema(tool)
default:
return nil
}
}
func extractMappedToolSchema(m map[string]any) *workspaceToolSchema {
name, _ := m["name"].(string)
desc, _ := m["description"].(string)
var schemaObj map[string]any
// OpenAI Chat nested function wrapper.
if fn, ok := m["function"].(map[string]any); ok {
if name == "" {
name, _ = fn["name"].(string)
}
if desc == "" {
desc, _ = fn["description"].(string)
}
if params, ok := fn["parameters"].(map[string]any); ok {
schemaObj = params
}
}
// Anthropic input_schema.
if schemaObj == nil {
if s, ok := m["input_schema"].(map[string]any); ok {
schemaObj = s
}
}
// Flat OpenAI parameters.
if schemaObj == nil {
if s, ok := m["parameters"].(map[string]any); ok {
schemaObj = s
}
}
if strings.TrimSpace(name) == "" {
return nil
}
return &workspaceToolSchema{
name: name,
description: desc,
schema: schemaObj,
properties: schemaObjectProperties(schemaObj),
}
}
// extractAnthropicToolSchema normalizes the concrete native Messages decoder
// value. InputSchema is deliberately decoded into a new map so a binding does
// not retain the request's RawMessage buffer or infer a role by reflection.
func extractAnthropicToolSchema(tool anthropicTool) *workspaceToolSchema {
if strings.TrimSpace(tool.Name) == "" || len(tool.InputSchema) == 0 {
return nil
}
decoder := json.NewDecoder(bytes.NewReader(tool.InputSchema))
decoder.UseNumber()
var schema map[string]any
if err := decoder.Decode(&schema); err != nil || schema == nil {
return nil
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return nil
}
return &workspaceToolSchema{
name: tool.Name,
description: tool.Description,
schema: cloneAnyMap(schema),
properties: schemaObjectProperties(schema),
}
}
// bindAlternative compiles a single configured alternative against the
// normalized actual tools. Every declared operation must bind, and the
// alternative must satisfy write-with-parents or separate-prepare completeness.
func bindAlternative(alt config.ExecutionWorkspaceToolAlternative, schemasByName map[string]*workspaceToolSchema) (*workspaceBinding, error) {
name := strings.TrimSpace(alt.Name)
ops := make(map[workspaceOperationKind]*workspaceOperationBinding, len(alt.Operations))
for _, kind := range canonicalOperationOrder {
cfgOp, ok := alt.Operations[string(kind)]
if !ok {
continue
}
opBinding, err := bindOperation(kind, cfgOp, schemasByName)
if err != nil {
return nil, fmt.Errorf("alternative %q operation %q: %w", name, kind, err)
}
ops[kind] = opBinding
}
if len(ops) == 0 {
return nil, fmt.Errorf("alternative %q declares no recognized operations", name)
}
if err := validateAlternativeCompleteness(name, ops); err != nil {
return nil, err
}
binding := &workspaceBinding{alternativeName: name, operations: ops}
binding.fingerprint = computeBindingFingerprint(binding)
return binding, nil
}
// validateAlternativeCompleteness enforces the write-with-parents or
// separate-prepare completeness invariant: a write operation that cannot create
// missing parents requires a prepare operation in the same alternative.
func validateAlternativeCompleteness(name string, ops map[workspaceOperationKind]*workspaceOperationBinding) error {
write, hasWrite := ops[opKindWrite]
if hasWrite && !write.createsParents {
if _, hasPrepare := ops[opKindPrepare]; !hasPrepare {
return fmt.Errorf("alternative %q: write cannot create parents and no prepare operation is declared", name)
}
}
return nil
}
// bindOperation binds one configured operation to its actual tool by exact name
// and recursive schema matcher, resolves the argument map, validates mapped
// fields against the actual schema, and copies the immutable result matcher.
func bindOperation(kind workspaceOperationKind, cfgOp config.ExecutionWorkspaceOperation, schemasByName map[string]*workspaceToolSchema) (*workspaceOperationBinding, error) {
toolName := strings.TrimSpace(cfgOp.ToolName)
if toolName == "" {
return nil, fmt.Errorf("tool_name must not be empty")
}
schema, ok := schemasByName[toolName]
if !ok {
return nil, fmt.Errorf("tool %q is not present in the request tools", toolName)
}
if len(cfgOp.SchemaMatcher) == 0 {
return nil, fmt.Errorf("schema_matcher must not be empty")
}
if !schemaMatcherMatches(cfgOp.SchemaMatcher, schema.schema) {
return nil, fmt.Errorf("tool %q schema does not satisfy the configured schema_matcher", toolName)
}
if len(cfgOp.ArgumentMap) == 0 {
return nil, fmt.Errorf("argument_map must not be empty")
}
if len(cfgOp.ResultMatcher) == 0 {
return nil, fmt.Errorf("result_matcher must not be empty")
}
ob := &workspaceOperationBinding{
op: kind,
toolName: toolName,
schemaMatcher: cloneAnyMap(cfgOp.SchemaMatcher),
argumentMap: cloneAnyMap(cfgOp.ArgumentMap),
resultMatcher: cloneAnyMap(cfgOp.ResultMatcher),
createsParents: cfgOp.CreatesParents,
normalizedSchema: cloneWorkspaceToolSchema(schema),
}
if err := resolveArgumentMap(ob, kind); err != nil {
return nil, err
}
if err := validateMappedFields(ob, schema); err != nil {
return nil, err
}
return ob, nil
}
// cloneWorkspaceToolSchema detaches the compiled binding from the request's
// decoded tool map. A caller can reuse or mutate its decoded request after
// admission, but that must not alter the request-local binding contract.
func cloneWorkspaceToolSchema(schema *workspaceToolSchema) *workspaceToolSchema {
if schema == nil {
return nil
}
return &workspaceToolSchema{
name: schema.name,
description: schema.description,
schema: cloneAnyMap(schema.schema),
properties: cloneAnyMap(schema.properties),
}
}
// resolveArgumentMap interprets the configured argument_map into structured or
// command encoding fields. The presence of a "command" field name selects
// command mode. A "path" mapping is always required; write additionally
// requires a "content" mapping.
func resolveArgumentMap(ob *workspaceOperationBinding, kind workspaceOperationKind) error {
am := ob.argumentMap
pathField, ok := stringField(am, "path")
if !ok {
return fmt.Errorf("argument_map requires a non-empty %q field name", "path")
}
ob.pathField = pathField
if content, ok := stringField(am, "content"); ok {
ob.contentField = content
}
if modeField, ok := stringField(am, "mode"); ok {
ob.modeField = modeField
}
if command, ok := stringField(am, "command"); ok {
ob.mode = modeCommand
ob.commandField = command
argv, err := parseArgvTemplate(am["argv"])
if err != nil {
return err
}
placeholders, err := validateCommandArgvTemplate(argv)
if err != nil {
return err
}
if placeholders["{path}"] != 1 {
return fmt.Errorf("command argv template must reference the {path} placeholder exactly once")
}
if kind == opKindWrite && placeholders["{content}"] != 1 {
return fmt.Errorf("write command argv template must reference the {content} placeholder exactly once")
}
ob.argvTemplate = argv
} else {
ob.mode = modeStructured
}
if kind == opKindWrite && ob.contentField == "" {
return fmt.Errorf("write argument_map requires a non-empty %q field name", "content")
}
return nil
}
// validateMappedFields ties the argument map to the actual tool schema. In
// structured mode every mapped field must be declared by the schema; in command
// mode the synthesized command field must be declared by the schema.
func validateMappedFields(ob *workspaceOperationBinding, schema *workspaceToolSchema) error {
check := func(role, field string) error {
if field == "" {
return nil
}
root := strings.SplitN(field, ".", 2)[0]
if _, ok := schema.properties[root]; !ok {
return fmt.Errorf("mapped %s field %q is not declared by tool %q schema", role, field, schema.name)
}
return nil
}
switch ob.mode {
case modeStructured:
if err := check("path", ob.pathField); err != nil {
return err
}
if err := check("content", ob.contentField); err != nil {
return err
}
if err := check("mode", ob.modeField); err != nil {
return err
}
case modeCommand:
if err := check("command", ob.commandField); err != nil {
return err
}
}
return nil
}
// schemaMatcherMatches reports whether the actual tool schema satisfies the
// configured recursive schema matcher (a deep subset match).
func schemaMatcherMatches(matcher map[string]any, schema map[string]any) bool {
if schema == nil {
schema = map[string]any{}
}
return deepSubsetMatch(map[string]any(matcher), map[string]any(schema))
}
// deepSubsetMatch reports whether actual contains everything declared by
// matcher. Maps match as subsets, slices require each matcher element to be
// found in actual, and scalars compare by value. A small operator vocabulary
// is supported for string matcher leaves: "$any", "$string", "$number",
// "$bool".
func deepSubsetMatch(matcher, actual any) bool {
switch m := matcher.(type) {
case map[string]any:
am, ok := actual.(map[string]any)
if !ok {
return false
}
for key, mv := range m {
av, ok := am[key]
if !ok {
return false
}
if !deepSubsetMatch(mv, av) {
return false
}
}
return true
case []any:
as, ok := actual.([]any)
if !ok {
return false
}
for _, mv := range m {
found := false
for _, av := range as {
if deepSubsetMatch(mv, av) {
found = true
break
}
}
if !found {
return false
}
}
return true
case string:
switch m {
case "$any":
return actual != nil
case "$string":
_, ok := actual.(string)
return ok
case "$number":
_, ok := toFloat(actual)
return ok
case "$bool":
_, ok := actual.(bool)
return ok
}
s, ok := actual.(string)
return ok && s == m
default:
return valuesEqual(matcher, actual)
}
}
// valuesEqual compares two scalar values, normalizing numeric types so that a
// config int and a decoded json.Number/float64 compare equal.
func valuesEqual(a, b any) bool {
if af, ok := toFloat(a); ok {
if bf, ok := toFloat(b); ok {
return af == bf
}
return false
}
return reflect.DeepEqual(a, b)
}
// toFloat converts any supported numeric representation to a float64.
func toFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int32:
return float64(n), true
case int64:
return float64(n), true
case json.Number:
if f, err := n.Float64(); err == nil {
return f, true
}
}
return 0, false
}
// computeBindingFingerprint produces a deterministic sha256 hex digest of the
// selected alternative's canonical configuration plus the normalized actual
// schemas. json.Marshal sorts object keys, so the digest is stable regardless
// of Go map iteration order or endpoint tool-definition shape.
func computeBindingFingerprint(b *workspaceBinding) string {
opsDesc := make(map[string]any, len(b.operations))
for kind, ob := range b.operations {
var normalizedSchema any
if ob.normalizedSchema != nil {
normalizedSchema = ob.normalizedSchema.schema
}
opsDesc[string(kind)] = map[string]any{
"tool_name": ob.toolName,
"mode": string(ob.mode),
"schema_matcher": ob.schemaMatcher,
"argument_map": ob.argumentMap,
"result_matcher": ob.resultMatcher,
"creates_parents": ob.createsParents,
"normalized_schema": normalizedSchema,
}
}
desc := map[string]any{
"alternative": b.alternativeName,
"operations": opsDesc,
}
raw, _ := json.Marshal(desc)
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
// stringField returns a trimmed non-empty string value for key, or false.
func stringField(m map[string]any, key string) (string, bool) {
v, ok := m[key]
if !ok {
return "", false
}
s, ok := v.(string)
if !ok {
return "", false
}
s = strings.TrimSpace(s)
if s == "" {
return "", false
}
return s, true
}
// parseArgvTemplate validates and copies the command argv template.
func parseArgvTemplate(v any) ([]string, error) {
raw, ok := v.([]any)
if !ok || len(raw) == 0 {
return nil, fmt.Errorf("command argument_map requires a non-empty %q template array", "argv")
}
out := make([]string, 0, len(raw))
for i, item := range raw {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("argv template token %d is not a string", i)
}
out = append(out, s)
}
return out, nil
}
// validateCommandArgvTemplate permits placeholders only as whole argv tokens.
// This makes command mapping unambiguous: Edge determines exactly which argv
// element receives each canonical value instead of accepting shell fragments or
// unsupported interpolation syntax.
func validateCommandArgvTemplate(argv []string) (map[string]int, error) {
counts := make(map[string]int, 2)
for _, token := range argv {
switch token {
case "{path}", "{content}":
counts[token]++
default:
if strings.ContainsAny(token, "{}") {
return nil, fmt.Errorf("command argv template has unsupported placeholder token %q", token)
}
}
}
return counts, nil
}
// cloneAnyMap deep-copies a decoded JSON map so the compiled binding is
// independent of later config mutation.
func cloneAnyMap(m map[string]any) map[string]any {
if m == nil {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = cloneAnyValue(v)
}
return out
}
func cloneAnyValue(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, vv := range t {
out[k] = cloneAnyValue(vv)
}
return out
case []any:
out := make([]any, len(t))
for i, vv := range t {
out[i] = cloneAnyValue(vv)
}
return out
default:
return t
}
}
// bindingFingerprint returns the immutable binding fingerprint.
func (b *workspaceBinding) bindingFingerprint() string { return b.fingerprint }
// operation returns the compiled operation binding for kind, or nil.
func (b *workspaceBinding) operation(kind workspaceOperationKind) *workspaceOperationBinding {
return b.operations[kind]
}
// createsParents reports whether the selected write operation creates missing
// parents. It returns false when the binding has no write operation.
func (b *workspaceBinding) createsParents() bool {
if write, ok := b.operations[opKindWrite]; ok {
return write.createsParents
}
return false
}