Claude의 단일 Anthropic 요청 안에서 IOP가 Plan, Work, Review와 workspace 도구 실행을 끝내고 실제 dev smoke로 계약을 검증할 수 있어야 한다.\n\n완료 task evidence와 마일스톤 검토 상태도 같은 변경에 고정한다.
218 lines
8.6 KiB
Go
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)
|
|
}
|