iop/apps/edge/internal/openai/single_request_quality_gate.go
toki d7a150c7fe feat(agent): 단일 요청 실행 경로를 완성한다
Claude의 단일 Anthropic 요청 안에서 IOP가 Plan, Work, Review와 workspace 도구 실행을 끝내고 실제 dev smoke로 계약을 검증할 수 있어야 한다.\n\n완료 task evidence와 마일스톤 검토 상태도 같은 변경에 고정한다.
2026-08-08 23:35:13 +09:00

218 lines
8.6 KiB
Go

package openai
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"sync"
edgeservice "iop/apps/edge/internal/service"
)
var errSingleRequestClosedTerminal = errors.New("single-request stage reached a closed terminal")
// singleRequestTerminalFailure transports only one validated closed
// disposition. The cause is always a package sentinel used for errors.Is; raw
// provider/tool errors are deliberately not retained.
type singleRequestTerminalFailure struct {
disposition edgeservice.SingleRequestTerminalDisposition
cause error
}
func (e *singleRequestTerminalFailure) Error() string { return errSingleRequestClosedTerminal.Error() }
func (e *singleRequestTerminalFailure) Unwrap() error { return e.cause }
func singleRequestTerminalDisposition(err error) (edgeservice.SingleRequestTerminalDisposition, bool) {
var terminal *singleRequestTerminalFailure
if !errors.As(err, &terminal) || terminal == nil || terminal.disposition.Validate() != nil {
return edgeservice.SingleRequestTerminalDisposition{}, false
}
return terminal.disposition, true
}
type singleRequestQualityGate struct {
mu sync.Mutex
toolCycles map[string]map[[sha256.Size]byte]struct{}
}
func newSingleRequestQualityGate() *singleRequestQualityGate {
return &singleRequestQualityGate{toolCycles: make(map[string]map[[sha256.Size]byte]struct{})}
}
func singleRequestQualityGateOrNew(gate *singleRequestQualityGate) *singleRequestQualityGate {
if gate != nil {
return gate
}
return newSingleRequestQualityGate()
}
func (g *singleRequestQualityGate) failure(kind edgeservice.SingleRequestTerminalKind, class edgeservice.SingleRequestTerminalErrorClass, cause error) error {
disposition := edgeservice.SingleRequestTerminalDisposition{Kind: kind, ErrorClass: class}
if disposition.Validate() != nil {
disposition = edgeservice.SingleRequestTerminalDisposition{Kind: edgeservice.SingleRequestTerminalError, ErrorClass: edgeservice.SingleRequestTerminalErrorProvider}
}
return &singleRequestTerminalFailure{disposition: disposition, cause: cause}
}
func (g *singleRequestQualityGate) reclassify(err, cause error) error {
if disposition, ok := singleRequestTerminalDisposition(err); ok {
return g.failure(disposition.Kind, disposition.ErrorClass, cause)
}
return g.providerFailure(context.Background(), err, cause)
}
func (g *singleRequestQualityGate) providerFailure(ctx context.Context, err, cause error) error {
if disposition, ok := singleRequestTerminalDisposition(err); ok {
return g.failure(disposition.Kind, disposition.ErrorClass, cause)
}
switch {
case ctx != nil && errors.Is(ctx.Err(), context.Canceled):
return g.failure(edgeservice.SingleRequestTerminalCancelled, "", cause)
case ctx != nil && errors.Is(ctx.Err(), context.DeadlineExceeded), errors.Is(err, context.DeadlineExceeded):
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorTimeout, cause)
case errors.Is(err, errProviderStageOutputLimit):
return g.failure(edgeservice.SingleRequestTerminalLength, "", cause)
case errors.Is(err, errProviderStageContextLimit):
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorContext, cause)
case errors.Is(err, errProviderStageMalformed):
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorMalformed, cause)
default:
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorProvider, cause)
}
}
func (g *singleRequestQualityGate) validation(cause error) error {
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorValidation, cause)
}
func (g *singleRequestQualityGate) malformed(cause error) error {
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorMalformed, cause)
}
func (g *singleRequestQualityGate) budget(cause error) error {
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorBudget, cause)
}
func (g *singleRequestQualityGate) length(cause error) error {
return g.failure(edgeservice.SingleRequestTerminalLength, "", cause)
}
func (g *singleRequestQualityGate) contextLimit(cause error) error {
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorContext, cause)
}
func (g *singleRequestQualityGate) internalTool(cause error) error {
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorInternalTool, cause)
}
func (g *singleRequestQualityGate) serviceFailure(ctx context.Context, err, cause error) error {
if disposition, ok := singleRequestTerminalDisposition(err); ok {
return g.failure(disposition.Kind, disposition.ErrorClass, cause)
}
switch {
case ctx != nil && errors.Is(ctx.Err(), context.Canceled), errors.Is(err, edgeservice.ErrSingleRequestCancelled):
return g.failure(edgeservice.SingleRequestTerminalCancelled, "", cause)
case ctx != nil && errors.Is(ctx.Err(), context.DeadlineExceeded), errors.Is(err, context.DeadlineExceeded):
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorTimeout, cause)
case errors.Is(err, edgeservice.ErrSingleRequestInternalToolBudget):
return g.budget(cause)
case errors.Is(err, edgeservice.ErrSingleRequestInternalToolInvalidCall), errors.Is(err, edgeservice.ErrSingleRequestInvalidState), errors.Is(err, edgeservice.ErrSingleRequestInvalidSequence):
return g.malformed(cause)
default:
return g.internalTool(cause)
}
}
// observeToolCycle hashes one bounded canonical action/result pair and rejects
// the first proven duplicate within the same request stage. Only fixed hashes
// are retained; arguments and tool output never enter guard state.
func (g *singleRequestQualityGate) observeToolCycle(stage, name string, arguments json.RawMessage, result edgeservice.InternalWorkspaceToolResult, cause error) error {
if g == nil || stage == "" || name == "" || len(arguments) == 0 {
return singleRequestQualityGateOrNew(g).malformed(cause)
}
success := result.Status == "success" && result.ErrorCode == ""
repairableNotFound := result.Status == "error" && result.ErrorCode == "not_found"
if !success && !repairableNotFound {
switch result.Status {
case "timeout":
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorTimeout, cause)
case "cancelled":
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorInternalTool, cause)
case "invalid":
return g.malformed(cause)
default:
if result.ErrorCode == "invalid_request" {
return g.malformed(cause)
}
return g.internalTool(cause)
}
}
canonicalArguments, err := canonicalSingleRequestJSON(arguments)
if err != nil {
return g.malformed(cause)
}
hash := sha256.New()
writeSingleRequestFingerprintPart(hash, []byte(name))
writeSingleRequestFingerprintPart(hash, canonicalArguments)
writeSingleRequestFingerprintPart(hash, []byte(result.Status))
writeSingleRequestFingerprintPart(hash, []byte(result.ErrorCode))
writeSingleRequestFingerprintPart(hash, result.Content)
for _, entry := range result.Entries {
writeSingleRequestFingerprintPart(hash, []byte(entry))
}
writeSingleRequestFingerprintPart(hash, result.Stdout)
writeSingleRequestFingerprintPart(hash, result.Stderr)
var scalar [5]byte
binary.BigEndian.PutUint32(scalar[:4], uint32(result.ExitCode))
if result.Truncated {
scalar[4] = 1
}
writeSingleRequestFingerprintPart(hash, scalar[:])
var fingerprint [sha256.Size]byte
copy(fingerprint[:], hash.Sum(nil))
g.mu.Lock()
defer g.mu.Unlock()
seen := g.toolCycles[stage]
if seen == nil {
seen = make(map[[sha256.Size]byte]struct{})
g.toolCycles[stage] = seen
}
if _, repeated := seen[fingerprint]; repeated {
return g.failure(edgeservice.SingleRequestTerminalError, edgeservice.SingleRequestTerminalErrorRepetition, cause)
}
seen[fingerprint] = struct{}{}
return nil
}
func canonicalSingleRequestJSON(raw []byte) ([]byte, error) {
if validateSingleRequestJSON(raw) != nil {
return nil, errProviderStageMalformed
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, errProviderStageMalformed
}
canonical, err := json.Marshal(value)
if err != nil {
return nil, errProviderStageMalformed
}
return canonical, nil
}
type singleRequestFingerprintWriter interface {
Write([]byte) (int, error)
}
func writeSingleRequestFingerprintPart(hash singleRequestFingerprintWriter, value []byte) {
var size [8]byte
binary.BigEndian.PutUint64(size[:], uint64(len(value)))
_, _ = hash.Write(size[:])
_, _ = hash.Write(value)
}