Contract codes now use the agent.* values, and runtime can emit structured logs without changing results when no logger is configured. Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
package pitransport
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
ResultV1SchemaVersion = 1
|
||
MaxResultMembers = 32
|
||
MaxResultMemberBytes = 2 << 20
|
||
MaxResultBodyBytes = 8 << 20
|
||
RunnerProtocolLegacy = "legacy"
|
||
RunnerProtocolTypedV1 = "typed-v1"
|
||
)
|
||
|
||
var ErrTypedResult = fmt.Errorf("pitransport: typed result_v1 rejected")
|
||
|
||
// 对外错误码与 Error-Code-System 的 agent 域一致。
|
||
// CANCELLED 并入 agent.canceled,MISSING_TARGET 并入 agent.missing_deliverable。
|
||
var allowedErrorCodes = map[string]struct{}{
|
||
"agent.prompt_empty": {},
|
||
"agent.unauthorized": {},
|
||
"agent.timeout": {},
|
||
"agent.canceled": {},
|
||
"agent.transient": {},
|
||
"agent.missing_deliverable": {},
|
||
"agent.run_failed": {},
|
||
}
|
||
|
||
var retryableErrorCodes = map[string]struct{}{
|
||
"agent.timeout": {},
|
||
"agent.canceled": {},
|
||
"agent.transient": {},
|
||
}
|
||
|
||
type ResultV1Member struct {
|
||
Kind string `json:"kind"`
|
||
Path string `json:"path"`
|
||
Digest string `json:"digest"`
|
||
Size int `json:"size"`
|
||
}
|
||
|
||
type ResultV1 struct {
|
||
SchemaVersion int `json:"schema_version"`
|
||
RunID string `json:"run_id"`
|
||
Outcome string `json:"outcome"`
|
||
Retryable bool `json:"retryable"`
|
||
ErrorCode string `json:"error_code,omitempty"`
|
||
Members []ResultV1Member `json:"members"`
|
||
ContentDigest string `json:"content_digest"`
|
||
}
|
||
|
||
func SafeRelPath(p string) bool {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" || filepath.IsAbs(p) {
|
||
return false
|
||
}
|
||
clean := filepath.ToSlash(filepath.Clean(p))
|
||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(p, "..") {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func DigestBytes(b []byte) string {
|
||
sum := sha256.Sum256(b)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func (r ResultV1) MembersDigest() string {
|
||
type row struct {
|
||
Kind string `json:"kind"`
|
||
Path string `json:"path"`
|
||
Digest string `json:"digest"`
|
||
Size int `json:"size"`
|
||
}
|
||
members := r.Members
|
||
if members == nil {
|
||
members = []ResultV1Member{}
|
||
}
|
||
rows := make([]row, 0, len(members))
|
||
for _, m := range members {
|
||
rows = append(rows, row{m.Kind, m.Path, m.Digest, m.Size})
|
||
}
|
||
raw, _ := json.Marshal(rows)
|
||
return DigestBytes(raw)
|
||
}
|
||
|
||
func (r ResultV1) Validate(expectedRunID string, patches map[string][]byte) error {
|
||
if r.SchemaVersion != ResultV1SchemaVersion {
|
||
return fmt.Errorf("%w: schema", ErrTypedResult)
|
||
}
|
||
if strings.TrimSpace(r.RunID) == "" {
|
||
return fmt.Errorf("%w: run_id", ErrTypedResult)
|
||
}
|
||
if expectedRunID != "" && r.RunID != expectedRunID {
|
||
return fmt.Errorf("%w: run mismatch", ErrTypedResult)
|
||
}
|
||
if r.Outcome != "success" && r.Outcome != "error" {
|
||
return fmt.Errorf("%w: outcome", ErrTypedResult)
|
||
}
|
||
if r.Outcome == "success" && strings.TrimSpace(r.ErrorCode) != "" {
|
||
return fmt.Errorf("%w: error_code", ErrTypedResult)
|
||
}
|
||
if r.Outcome == "error" {
|
||
if _, ok := allowedErrorCodes[r.ErrorCode]; !ok {
|
||
return fmt.Errorf("%w: error_code", ErrTypedResult)
|
||
}
|
||
}
|
||
if len(r.Members) > MaxResultMembers {
|
||
return fmt.Errorf("%w: members", ErrTypedResult)
|
||
}
|
||
total := 0
|
||
seen := map[string]struct{}{}
|
||
for _, m := range r.Members {
|
||
if m.Kind != "file" || !SafeRelPath(m.Path) {
|
||
return fmt.Errorf("%w: member path", ErrTypedResult)
|
||
}
|
||
if _, dup := seen[m.Path]; dup {
|
||
return fmt.Errorf("%w: duplicate member", ErrTypedResult)
|
||
}
|
||
seen[m.Path] = struct{}{}
|
||
if m.Size < 0 || m.Size > MaxResultMemberBytes {
|
||
return fmt.Errorf("%w: member size", ErrTypedResult)
|
||
}
|
||
total += m.Size
|
||
body, ok := patches[m.Path]
|
||
if !ok {
|
||
return fmt.Errorf("%w: member missing body", ErrTypedResult)
|
||
}
|
||
if len(body) != m.Size || DigestBytes(body) != m.Digest {
|
||
return fmt.Errorf("%w: digest", ErrTypedResult)
|
||
}
|
||
}
|
||
if total > MaxResultBodyBytes {
|
||
return fmt.Errorf("%w: body", ErrTypedResult)
|
||
}
|
||
if r.ContentDigest != r.MembersDigest() {
|
||
return fmt.Errorf("%w: content_digest", ErrTypedResult)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func TypedRetryable(r ResultV1) bool {
|
||
if r.Outcome != "error" || !r.Retryable {
|
||
return false
|
||
}
|
||
_, ok := retryableErrorCodes[r.ErrorCode]
|
||
return ok
|
||
}
|
||
|
||
func BuildResultV1(runID, outcome string, retryable bool, errorCode string, patches []struct {
|
||
RelPath string
|
||
Content []byte
|
||
}) ResultV1 {
|
||
members := make([]ResultV1Member, 0, len(patches))
|
||
for _, p := range patches {
|
||
if !SafeRelPath(p.RelPath) {
|
||
continue
|
||
}
|
||
members = append(members, ResultV1Member{
|
||
Kind: "file", Path: p.RelPath, Digest: DigestBytes(p.Content), Size: len(p.Content),
|
||
})
|
||
}
|
||
r := ResultV1{
|
||
SchemaVersion: ResultV1SchemaVersion,
|
||
RunID: runID,
|
||
Outcome: outcome,
|
||
Retryable: retryable,
|
||
ErrorCode: errorCode,
|
||
Members: members,
|
||
}
|
||
r.ContentDigest = r.MembersDigest()
|
||
return r
|
||
}
|