caller별 예외 대신 요청 의미와 protocol profile capability로 operation을 선택해 tools와 effort 조합을 보존한다. 지원하지 않는 effort는 가장 가까운 하위 등급으로만 내리고 상향 매핑은 거부한다.
455 lines
14 KiB
Go
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
|
|
}
|