iop/apps/edge/internal/openai/hot_path_selector.go
toki e5869a53ae fix(edge): provider 호출 정규화 계층을 추가한다
caller별 예외 대신 요청 의미와 protocol profile capability로 operation을 선택해 tools와 effort 조합을 보존한다. 지원하지 않는 effort는 가장 가까운 하위 등급으로만 내리고 상향 매핑은 거부한다.
2026-08-13 23:06:50 +09:00

455 lines
14 KiB
Go

package openai
import (
"encoding/json"
"fmt"
"path"
"path/filepath"
"reflect"
"sort"
"strings"
"iop/packages/go/config"
)
const (
modeDirect = config.ModeDirect
modeLight = config.ModeLight
)
const (
reasonDirectNoReservedControls = "direct_no_reserved_controls"
reasonLightExactPrepare = "light_exact_prepare"
reasonLightExactPair = "light_exact_pair"
reasonMalformedPartialPair = "malformed_partial_pair"
reasonMalformedMixedCalls = "malformed_mixed_calls"
reasonMalformedDuplicateCalls = "malformed_duplicate_calls"
reasonMalformedWrongPath = "malformed_wrong_path"
reasonMalformedControlRole = "malformed_control_role"
reasonMalformedConflictingPath = "malformed_conflicting_path"
reasonModeDisabled = "mode_disabled"
reasonUnhealthyRoute = "unhealthy_route"
reasonArtifactRequired = "artifact_required"
)
type reservedPaths struct {
RequestID string
JobDir string // e.g. ".iop/job/<request_id>"
PlanPath string // e.g. ".iop/job/<request_id>/plan.md"
ReviewPath string // e.g. ".iop/job/<request_id>/review.md"
}
func newReservedPaths(requestID string) reservedPaths {
cleanID := strings.TrimSpace(requestID)
jobDir := ".iop/job/" + cleanID
return reservedPaths{
RequestID: cleanID,
JobDir: jobDir,
PlanPath: jobDir + "/plan.md",
ReviewPath: jobDir + "/review.md",
}
}
type normalizedToolCall struct {
ID string `json:"id"`
ProviderCallID string `json:"provider_call_id,omitempty"`
Name string `json:"name"`
Arguments map[string]any `json:"arguments,omitempty"`
RawArgs string `json:"raw_args,omitempty"`
Path string `json:"path,omitempty"`
}
type normalizedStageDeltaKind string
const (
normalizedStageDeltaText normalizedStageDeltaKind = "text"
normalizedStageDeltaReasoning normalizedStageDeltaKind = "reasoning"
normalizedStageDeltaTool normalizedStageDeltaKind = "tool"
)
// normalizedStageDelta preserves provider-independent delta order after the
// selected provider decoder has done its work. Caller codecs consume this
// shape and never parse the selected provider wire again.
type normalizedStageDelta struct {
Kind normalizedStageDeltaKind
Text string
ToolID string
ToolName string
Arguments string
}
type normalizedStageOutput struct {
ResponseID string `json:"response_id,omitempty"`
Created int64 `json:"created,omitempty"`
Content string `json:"content,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
ReasoningSignature string `json:"reasoning_signature,omitempty"`
ToolCalls []normalizedToolCall `json:"tool_calls,omitempty"`
TerminalReason string `json:"terminal_reason,omitempty"`
Usage json.RawMessage `json:"usage,omitempty"`
OpenAIUsage *openAIUsage `json:"-"`
Deltas []normalizedStageDelta `json:"-"`
ProgressivelyReleased bool `json:"-"`
CallerStageOnly bool `json:"-"`
}
// hotPathSelectorGate is immutable evidence from the single provider-pool
// admission that produced output. Classification never substitutes a caller
// flag or re-resolves mutable catalog state for these facts.
type hotPathSelectorGate struct {
PresetID string
SelectorModel string
ModelGroupKey string
ProviderID string
RunID string
NodeID string
ExecutionPath string
ProfileDriver string
ProfileOperation string
ProfileCapabilities []string
Healthy bool
CapabilitySatisfied bool
}
type hotPathDecision struct {
Mode string `json:"mode"`
Reason string `json:"reason"`
PrepareCall *normalizedToolCall `json:"prepare_call,omitempty"`
PairCalls []normalizedToolCall `json:"pair_calls,omitempty"`
GeneralCalls []normalizedToolCall `json:"general_calls,omitempty"`
}
func classifyHotPathOutput(preset config.ExecutionPreset, issuedPaths reservedPaths, output normalizedStageOutput, gate hotPathSelectorGate) (hotPathDecision, error) {
if !gate.Healthy || !gate.CapabilitySatisfied || gate.PresetID != preset.ID || gate.SelectorModel != preset.Selector.Model ||
strings.TrimSpace(gate.ModelGroupKey) == "" || strings.TrimSpace(gate.ProviderID) == "" ||
strings.TrimSpace(gate.RunID) == "" || strings.TrimSpace(gate.NodeID) == "" ||
strings.TrimSpace(gate.ExecutionPath) == "" || strings.TrimSpace(gate.ProfileDriver) == "" {
return hotPathDecision{Reason: reasonUnhealthyRoute}, fmt.Errorf("route capability or health gate check failed (%s)", reasonUnhealthyRoute)
}
var prepareCalls []normalizedToolCall
var planCalls []normalizedToolCall
var reviewCalls []normalizedToolCall
var wrongPathCalls []normalizedToolCall
var generalCalls []normalizedToolCall
for _, tc := range output.ToolCalls {
control, err := classifyReservedControlCall(preset, issuedPaths, tc)
if err != nil {
return hotPathDecision{Reason: control.reason}, err
}
switch control.kind {
case "":
generalCalls = append(generalCalls, tc)
case "prepare":
prepareCalls = append(prepareCalls, tc)
case "plan":
planCalls = append(planCalls, tc)
case "review":
reviewCalls = append(reviewCalls, tc)
default:
wrongPathCalls = append(wrongPathCalls, tc)
}
}
if len(wrongPathCalls) > 0 {
return hotPathDecision{Reason: reasonMalformedWrongPath}, fmt.Errorf("malformed output: tool call targets wrong or invalid reserved path (%s)", reasonMalformedWrongPath)
}
reservedCount := len(prepareCalls) + len(planCalls) + len(reviewCalls)
// Mode Direct Candidate
if reservedCount == 0 {
if !isModeAllowed(preset, modeDirect) {
return hotPathDecision{Reason: reasonModeDisabled}, fmt.Errorf("mode %q is disabled for preset %q (%s)", modeDirect, preset.ID, reasonModeDisabled)
}
return hotPathDecision{
Mode: modeDirect,
Reason: reasonDirectNoReservedControls,
GeneralCalls: generalCalls,
}, nil
}
// Mode Light Candidate
if !isModeAllowed(preset, modeLight) {
return hotPathDecision{Reason: reasonModeDisabled}, fmt.Errorf("mode %q is disabled for preset %q (%s)", modeLight, preset.ID, reasonModeDisabled)
}
if len(generalCalls) > 0 {
return hotPathDecision{Reason: reasonMalformedMixedCalls}, fmt.Errorf("malformed output: mixed reserved controls and general tool calls (%s)", reasonMalformedMixedCalls)
}
if len(prepareCalls) > 1 || len(planCalls) > 1 || len(reviewCalls) > 1 {
return hotPathDecision{Reason: reasonMalformedDuplicateCalls}, fmt.Errorf("malformed output: duplicate reserved control calls (%s)", reasonMalformedDuplicateCalls)
}
// Exact Prepare
if len(prepareCalls) == 1 && len(planCalls) == 0 && len(reviewCalls) == 0 {
prep := prepareCalls[0]
return hotPathDecision{
Mode: modeLight,
Reason: reasonLightExactPrepare,
PrepareCall: &prep,
}, nil
}
// Exact Pair
if len(prepareCalls) == 0 && len(planCalls) == 1 && len(reviewCalls) == 1 {
return hotPathDecision{
Mode: modeLight,
Reason: reasonLightExactPair,
PairCalls: []normalizedToolCall{planCalls[0], reviewCalls[0]},
}, nil
}
return hotPathDecision{Reason: reasonMalformedPartialPair}, fmt.Errorf("malformed output: partial reserved control pair (%s)", reasonMalformedPartialPair)
}
type reservedControlClassification struct {
kind string
reason string
}
func classifyReservedControlCall(preset config.ExecutionPreset, issued reservedPaths, tc normalizedToolCall) (reservedControlClassification, error) {
sources := reservedPathSourcesFromToolCall(tc)
paths := reservedPathsFromToolCall(tc)
if len(sources) == 0 {
return reservedControlClassification{}, nil
}
if len(sources) != 1 || len(paths) != 1 {
return reservedControlClassification{reason: reasonMalformedConflictingPath}, fmt.Errorf("malformed output: conflicting reserved path sources (%s)", reasonMalformedConflictingPath)
}
observed := paths[0]
type roleMatch struct {
role string
path string
}
var matches []roleMatch
for _, alternative := range preset.WorkspaceTools {
for _, role := range []string{"prepare", "write"} {
op, ok := alternative.Operations[role]
if !ok || strings.TrimSpace(op.ToolName) != strings.TrimSpace(tc.Name) {
continue
}
mappedPath, ok := mappedControlPath(tc, op)
if !ok {
continue
}
matches = append(matches, roleMatch{role: role, path: mappedPath})
}
}
if len(matches) == 0 {
return reservedControlClassification{reason: reasonMalformedControlRole}, fmt.Errorf("malformed output: reserved path used by a non-canonical control role (%s)", reasonMalformedControlRole)
}
cleanJobDir := cleanRelativePath(issued.JobDir)
cleanPlan := cleanRelativePath(issued.PlanPath)
cleanReview := cleanRelativePath(issued.ReviewPath)
for _, match := range matches {
if match.path != observed {
continue
}
switch {
case match.role == "prepare" && observed == cleanJobDir:
return reservedControlClassification{kind: "prepare"}, nil
case match.role == "write" && observed == cleanPlan:
return reservedControlClassification{kind: "plan"}, nil
case match.role == "write" && observed == cleanReview:
return reservedControlClassification{kind: "review"}, nil
}
}
if observed != cleanJobDir && observed != cleanPlan && observed != cleanReview {
return reservedControlClassification{reason: reasonMalformedWrongPath}, fmt.Errorf("malformed output: tool call targets wrong or invalid reserved path (%s)", reasonMalformedWrongPath)
}
for _, match := range matches {
if match.path != observed {
return reservedControlClassification{reason: reasonMalformedWrongPath}, fmt.Errorf("malformed output: mapped control path must equal the complete issued path (%s)", reasonMalformedWrongPath)
}
}
return reservedControlClassification{reason: reasonMalformedControlRole}, fmt.Errorf("malformed output: canonical control role does not match reserved path (%s)", reasonMalformedControlRole)
}
func mappedControlPath(tc normalizedToolCall, op config.ExecutionWorkspaceOperation) (string, bool) {
mapped, ok := op.ArgumentMap["path"].(string)
if !ok || strings.TrimSpace(mapped) == "" {
return "", false
}
value, ok := lookupMappedArgument(tc.Arguments, mapped)
if !ok && tc.RawArgs != "" {
var args map[string]any
decoder := json.NewDecoder(strings.NewReader(tc.RawArgs))
decoder.UseNumber()
if decoder.Decode(&args) == nil {
value, ok = lookupMappedArgument(args, mapped)
}
}
if !ok {
return "", false
}
text, ok := value.(string)
if !ok {
return "", false
}
mappedPath := cleanRelativePath(text)
if mappedPath == "" || mappedPath == "." {
return "", false
}
return mappedPath, true
}
func lookupMappedArgument(arguments map[string]any, mapped string) (any, bool) {
if arguments == nil {
return nil, false
}
parts := strings.Split(mapped, ".")
var current any = arguments
for _, part := range parts {
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = object[part]
if !ok {
return nil, false
}
}
return current, true
}
func reservedPathsFromToolCall(tc normalizedToolCall) []string {
set := make(map[string]struct{})
for _, item := range reservedPathSourcesFromToolCall(tc) {
set[item] = struct{}{}
}
paths := make([]string, 0, len(set))
for item := range set {
paths = append(paths, item)
}
sort.Strings(paths)
return paths
}
// reservedPathSourcesFromToolCall preserves each independently supplied
// reserved-path occurrence. RawArgs normally serializes Arguments for normalized
// provider calls, so an equivalent decoded copy is not counted twice. A raw
// argument that differs from the decoded argument is still an independent source
// and must be rejected if it contains a reserved path.
func reservedPathSourcesFromToolCall(tc normalizedToolCall) []string {
var paths []string
add := func(value string) {
paths = append(paths, reservedPathsFromString(value)...)
}
add(tc.Path)
if tc.Arguments != nil {
collectReservedStrings(tc.Arguments, add)
if tc.RawArgs == "" {
return paths
}
var decoded map[string]any
decoder := json.NewDecoder(strings.NewReader(tc.RawArgs))
decoder.UseNumber()
if decoder.Decode(&decoded) == nil && decoded != nil {
if !reflect.DeepEqual(decoded, tc.Arguments) {
collectReservedStrings(decoded, add)
}
return paths
}
add(tc.RawArgs)
return paths
}
if tc.RawArgs == "" {
return paths
}
var decoded any
decoder := json.NewDecoder(strings.NewReader(tc.RawArgs))
decoder.UseNumber()
if decoder.Decode(&decoded) == nil {
collectReservedStrings(decoded, add)
} else {
add(tc.RawArgs)
}
return paths
}
func collectReservedStrings(value any, add func(string)) {
switch typed := value.(type) {
case string:
add(typed)
case map[string]any:
for _, item := range typed {
collectReservedStrings(item, add)
}
case []any:
for _, item := range typed {
collectReservedStrings(item, add)
}
}
}
func reservedPathsFromString(value string) []string {
normalized := strings.ReplaceAll(value, `\/`, "/")
normalized = filepath.ToSlash(normalized)
var paths []string
for search := normalized; ; {
idx := strings.Index(search, ".iop/job")
if idx < 0 {
break
}
candidate := search[idx:]
end := len(candidate)
for i, ch := range candidate {
if ch == ' ' || ch == '\t' || ch == '\n' || ch == '"' || ch == '\'' || ch == '`' || ch == ';' || ch == ',' || ch == '}' || ch == ']' || ch == ')' {
end = i
break
}
}
paths = append(paths, cleanRelativePath(candidate[:end]))
advance := idx + len(".iop/job")
if advance >= len(search) {
break
}
search = search[advance:]
}
return paths
}
func isModeAllowed(preset config.ExecutionPreset, mode string) bool {
for _, m := range preset.AllowedModes {
if m == mode {
return true
}
}
return false
}
func extractPathFromToolCall(tc normalizedToolCall) string {
paths := reservedPathsFromToolCall(tc)
if len(paths) == 1 {
return paths[0]
}
return ""
}
func extractIopJobPath(s string) string {
idx := strings.Index(s, ".iop/job/")
if idx < 0 {
return ""
}
sub := s[idx:]
for i, ch := range sub {
if ch == ' ' || ch == '\t' || ch == '\n' || ch == '"' || ch == '\'' || ch == '`' || ch == ';' {
return sub[:i]
}
}
return sub
}
func cleanRelativePath(p string) string {
p = strings.TrimSpace(p)
p = filepath.ToSlash(p)
p = path.Clean(p)
p = strings.TrimPrefix(p, "./")
p = strings.TrimSuffix(p, "/")
return p
}