iop/packages/go/agentruntime/failure.go

144 lines
4 KiB
Go

package agentruntime
import (
"context"
"encoding/json"
"errors"
"fmt"
)
const failureSchemaVersion = 1
// FailureCode is the stable, provider-neutral classification of an execution
// failure. Provider-specific diagnostics belong in Message or Metadata.
type FailureCode string
const (
FailureCodeUnknown FailureCode = "unknown"
FailureCodeCancelled FailureCode = "cancelled"
FailureCodeDeadlineExceeded FailureCode = "deadline_exceeded"
FailureCodeInvalidRequest FailureCode = "invalid_request"
FailureCodeSessionNotFound FailureCode = "session_not_found"
FailureCodeUnavailable FailureCode = "unavailable"
FailureCodeQuotaExhausted FailureCode = "quota_exhausted"
FailureCodeProcessExit FailureCode = "process_exit"
FailureCodeProvider FailureCode = "provider_error"
FailureCodeInternal FailureCode = "internal"
)
// Failure is the public typed failure value shared by runtime hosts.
type Failure struct {
Code FailureCode `json:"code"`
Message string `json:"message,omitempty"`
Retryable bool `json:"retryable"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func (f *Failure) Error() string {
if f == nil {
return ""
}
if f.Message != "" {
return f.Message
}
return string(f.Code)
}
type failureEnvelope struct {
SchemaVersion int `json:"schema_version"`
Failure *Failure `json:"failure"`
}
// EncodeFailure serializes a typed failure for durable host boundaries.
func EncodeFailure(f *Failure) (string, error) {
if f == nil {
return "", errors.New("agentruntime: nil failure")
}
normalized := normalizeFailure(*f)
payload, err := json.Marshal(failureEnvelope{
SchemaVersion: failureSchemaVersion,
Failure: &normalized,
})
if err != nil {
return "", fmt.Errorf("agentruntime: encode failure: %w", err)
}
return string(payload), nil
}
// DecodeFailure parses a durable failure envelope. Unknown future codes are
// preserved in metadata and normalized to FailureCodeUnknown.
func DecodeFailure(payload string) (*Failure, error) {
var envelope failureEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return nil, fmt.Errorf("agentruntime: decode failure: %w", err)
}
if envelope.SchemaVersion != failureSchemaVersion {
return nil, fmt.Errorf("agentruntime: unsupported failure schema version %d", envelope.SchemaVersion)
}
if envelope.Failure == nil {
return nil, errors.New("agentruntime: failure payload is missing")
}
normalized := normalizeFailure(*envelope.Failure)
return &normalized, nil
}
// FailureFromError maps cancellation boundaries and otherwise returns a
// provider-neutral unknown failure without guessing provider policy.
func FailureFromError(err error) *Failure {
if err == nil {
return nil
}
switch {
case errors.Is(err, ErrRunCancelled), errors.Is(err, context.Canceled):
return &Failure{Code: FailureCodeCancelled, Message: err.Error()}
case errors.Is(err, context.DeadlineExceeded):
return &Failure{Code: FailureCodeDeadlineExceeded, Message: err.Error(), Retryable: true}
default:
return &Failure{Code: FailureCodeUnknown, Message: err.Error()}
}
}
func normalizeFailure(f Failure) Failure {
if isKnownFailureCode(f.Code) {
return f
}
metadata := cloneStringMap(f.Metadata)
if metadata == nil {
metadata = make(map[string]string, 1)
}
if f.Code != "" {
metadata["original_code"] = string(f.Code)
}
f.Code = FailureCodeUnknown
f.Metadata = metadata
return f
}
func isKnownFailureCode(code FailureCode) bool {
switch code {
case FailureCodeUnknown,
FailureCodeCancelled,
FailureCodeDeadlineExceeded,
FailureCodeInvalidRequest,
FailureCodeSessionNotFound,
FailureCodeUnavailable,
FailureCodeQuotaExhausted,
FailureCodeProcessExit,
FailureCodeProvider,
FailureCodeInternal:
return true
default:
return false
}
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
cloned := make(map[string]string, len(input))
for key, value := range input {
cloned[key] = value
}
return cloned
}