iop/apps/edge/internal/openai/hot_path_selector.go
toki 495996fee4 feat(openai): 핫패스 에이전트 실행 경로를 확장한다
Anthropic·Chat 게이트와 관찰·종료 제어를 통합하고 관련 계약·검증 산출물을 반영한다.
2026-08-06 00:09:24 +09:00

454 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
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
}