iop/apps/edge/internal/openai/single_request_preset_binding.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

232 lines
8.6 KiB
Go

package openai
import (
"errors"
"fmt"
"reflect"
"iop/apps/edge/internal/authprojection"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
)
var (
errSingleRequestBindingMissingStage = errors.New("single-request binding: missing stage")
errSingleRequestBindingDuplicate = errors.New("single-request binding: duplicate stage role")
errSingleRequestBindingUnauthorized = errors.New("single-request binding: stage model not authorized for principal")
errSingleRequestBindingDynamic = errors.New("single-request binding: stage model dynamically selected")
errSingleRequestBindingInconsistent = errors.New("single-request binding: option-inconsistent stage")
)
// compileSingleRequestBinding builds the surface-neutral immutable admission
// value from an authorized execution preset and its resolved canonical
// bindings. It is called only after the preset's selector and every referenced
// stage model have been verified through their canonical catalog bindings for
// the authenticated principal.
//
// The function rejects missing, duplicate, unauthorized, dynamically selected,
// or option-inconsistent inputs without generic fallback. It keeps the
// external model echo equal to the requested public model.
func compileSingleRequestBinding(
publicModel string,
preset config.ExecutionPreset,
bindings map[string]routeDispatch,
view authprojection.AuthenticatedView,
) (*edgeservice.SingleRequestBinding, error) {
if preset.SingleRequest == nil {
return nil, nil
}
sr := preset.SingleRequest
// Defense-in-depth: independently re-verify the approved fixed shape at the
// admission boundary instead of trusting only the load-time config
// validation. A refreshed or crafted preset that no longer matches the frozen
// plan→work→review light shape must not compile an immutable admission.
if err := validateFixedSingleRequestShape(preset, sr); err != nil {
return nil, err
}
// Build the stage bindings from the preset's approved plan/work/review stage
// map, resolved through the canonical bindings authorized for the principal.
// Each stage's approved options come from the frozen policy config, never
// from dynamic provider dispatch metadata.
planBinding, err := resolveStageBinding("plan", sr.Stages.Plan, bindings, view)
if err != nil {
return nil, fmt.Errorf("single-request plan stage: %w", err)
}
workBinding, err := resolveStageBinding("work", sr.Stages.Work, bindings, view)
if err != nil {
return nil, fmt.Errorf("single-request work stage: %w", err)
}
reviewBinding, err := resolveStageBinding("review", sr.Stages.Review, bindings, view)
if err != nil {
return nil, fmt.Errorf("single-request review stage: %w", err)
}
srLimits := sr.Limits
limits := edgeservice.SingleRequestLimits{
WallClockMS: srLimits.WallClockMS,
StageTimeoutMS: srLimits.StageTimeoutMS,
MaxToolIterations: srLimits.MaxToolIterations,
MaxOutputBytes: srLimits.MaxOutputBytes,
}
return edgeservice.NewSingleRequestBinding(
publicModel,
sr.WorkspaceRef,
*planBinding,
*workBinding,
*reviewBinding,
limits,
)
}
// validateFixedSingleRequestShape re-verifies the approved immutable
// single-request shape at admission time. It independently confirms the
// selector, allowed modes, and the single light route match the frozen
// plan→work→review policy stages, including high reasoning on plan/review and
// no reasoning option on work. Every violation maps to a typed single-request
// binding error without generic fallback.
func validateFixedSingleRequestShape(preset config.ExecutionPreset, sr *config.ExecutionSingleRequestPolicy) error {
// Allowed modes must be exactly ["light"].
if len(preset.AllowedModes) != 1 || preset.AllowedModes[0] != config.ModeLight {
return errSingleRequestBindingInconsistent
}
// The fused selector must exactly match the fixed plan stage (model and
// options); the selector cannot diverge from the frozen plan binding.
if preset.Selector.Model != sr.Stages.Plan.Model || !singleRequestOptionsEqual(preset.Selector.Options, sr.Stages.Plan.Options) {
return errSingleRequestBindingInconsistent
}
// Plan and review must declare high reasoning; work must not declare it.
if singleRequestReasoningEffort(sr.Stages.Plan.Options) != config.SingleRequestReasoningEffortHigh {
return errSingleRequestBindingInconsistent
}
if singleRequestReasoningEffort(sr.Stages.Review.Options) != config.SingleRequestReasoningEffortHigh {
return errSingleRequestBindingInconsistent
}
if _, present := sr.Stages.Work.Options["reasoning_effort"]; present {
return errSingleRequestBindingInconsistent
}
// Exactly one light route with ordered, unique plan→work→review roles whose
// model and options exactly match the frozen policy stages.
if len(preset.Routes) != 1 {
return errSingleRequestBindingInconsistent
}
route, ok := preset.Routes[config.ModeLight]
if !ok {
return errSingleRequestBindingMissingStage
}
expected := []struct {
role string
stage config.ExecutionSingleRequestStageConfig
}{
{"plan", sr.Stages.Plan},
{"work", sr.Stages.Work},
{"review", sr.Stages.Review},
}
if len(route.Stages) != len(expected) {
return errSingleRequestBindingMissingStage
}
seenRoles := make(map[string]struct{}, len(route.Stages))
for _, stage := range route.Stages {
if _, dup := seenRoles[stage.Role]; dup {
return errSingleRequestBindingDuplicate
}
seenRoles[stage.Role] = struct{}{}
}
for i, want := range expected {
st := route.Stages[i]
if st.Role != want.role {
return errSingleRequestBindingInconsistent
}
if st.Model != want.stage.Model {
// The route would dynamically select a downstream model other than
// the frozen policy stage model.
return errSingleRequestBindingDynamic
}
if !singleRequestOptionsEqual(st.Options, want.stage.Options) {
return errSingleRequestBindingInconsistent
}
}
return nil
}
// resolveStageBinding maps a frozen stage config to its authorized routeDispatch
// binding and copies the approved stage options into a service DTO. It verifies
// that the binding is present, managed, principal-consistent, and names exactly
// the canonical model the frozen stage declares.
func resolveStageBinding(role string, stage config.ExecutionSingleRequestStageConfig, bindings map[string]routeDispatch, view authprojection.AuthenticatedView) (*edgeservice.SingleRequestStageBinding, error) {
canonicalModel := stage.Model
dispatch, ok := bindings[canonicalModel]
if !ok {
return nil, errSingleRequestBindingMissingStage
}
// The binding must come from a managed principal resolution. Unmanaged
// legacy routes cannot back a single-request admission.
if !dispatch.Managed {
return nil, errSingleRequestBindingUnauthorized
}
// Verify the binding's model group matches the canonical reference.
if dispatch.ModelGroupKey != canonicalModel {
return nil, errSingleRequestBindingInconsistent
}
// Verify the binding's principal matches the authenticated view.
if dispatch.PrincipalRef != view.Principal.PrincipalRef {
return nil, errSingleRequestBindingUnauthorized
}
// Copy the approved stage-level options from the frozen policy stage config,
// not from dynamic provider dispatch metadata. NewSingleRequestBinding takes
// a defensive deep copy, so a later config refresh cannot mutate an admitted
// binding through this reference.
return &edgeservice.SingleRequestStageBinding{
Model: canonicalModel,
Options: stage.Options,
}, nil
}
// compileSingleRequestBindingForUnmanaged builds the service binding from an
// unmanaged (legacy) preset resolution. It rejects the compilation because
// single-request admission requires managed principal authorization.
func compileSingleRequestBindingForUnmanaged(publicModel string, preset config.ExecutionPreset) (*edgeservice.SingleRequestBinding, error) {
if preset.SingleRequest == nil {
return nil, nil
}
// Unmanaged presets cannot back a single-request admission because there
// is no authenticated principal to verify stage authorization against.
return nil, errSingleRequestBindingUnauthorized
}
// singleRequestOptionsEqual reports whether two option maps are equal, treating
// nil and empty maps as equal.
func singleRequestOptionsEqual(a, b map[string]any) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
return reflect.DeepEqual(a, b)
}
// singleRequestReasoningEffort extracts the reasoning_effort option value from a
// stage's options map, returning "" when absent or non-string.
func singleRequestReasoningEffort(opts map[string]any) string {
if opts == nil {
return ""
}
v, ok := opts["reasoning_effort"]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}