454 lines
14 KiB
Go
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
|
|
}
|