diff --git a/.gitignore b/.gitignore index 37e230a..6f6c9a1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ go.work.sum # .vscode/ tmp/ +.twilight/ +/twilight-agent diff --git a/agent/artifact/artifact.go b/agent/artifact/artifact.go new file mode 100644 index 0000000..9d2906c --- /dev/null +++ b/agent/artifact/artifact.go @@ -0,0 +1,454 @@ +// Package artifact is the Artifact Core (docs/design/agent-artifact.md): +// Ref, Binding and the two-state RetentionLedger. The ledger +// persists itself; claims are activated before the owner fact is appended and +// orphans are released by the pre-collection reconciliation (ART-RET-3). +package artifact + +import ( + "context" + "errors" + "fmt" + "sort" + "sync" + + "github.com/felinics/twilight/agent/es" +) + +type ( + WireVersion uint16 + Scheme string + Authority string + Key string + BindingID string + BindingDigest string + ClaimID string + RefSetDigest string +) + +const WireVersion1 WireVersion = 1 + +type Durability string + +const ( + Ephemeral Durability = "ephemeral" + EventBound Durability = "event_bound" + Pinned Durability = "pinned" +) + +// Rank orders durabilities: Ephemeral < EventBound < Pinned. +func (d Durability) Rank() int { + switch d { + case Ephemeral: + return 0 + case EventBound: + return 1 + case Pinned: + return 2 + default: + return -1 + } +} + +type Integrity struct { + Algorithm string `json:"algorithm"` + Value string `json:"value"` +} + +// Ref locates and verifies immutable content (ART-REF-1). +type Ref struct { + Scheme Scheme `json:"scheme"` + Authority Authority `json:"authority"` + Key Key `json:"key"` + MediaType string `json:"mediaType,omitempty"` + SizeBytes *uint64 `json:"sizeBytes,omitempty"` + Integrity *Integrity `json:"integrity,omitempty"` + Durability Durability `json:"durability"` + ExpiresAtUnixMilli *int64 `json:"expiresAtUnixMilli,omitempty"` +} + +// Validate applies ART-ID-1 and ART-REF-2. +func (r Ref) Validate() error { + if r.Scheme == "" || r.Authority == "" || r.Key == "" { + return &Error{Code: ErrInvalid, Operation: "ref", Detail: "empty locator component"} + } + if r.Durability.Rank() < 0 { + return &Error{Code: ErrInvalid, Operation: "ref", Detail: "unknown durability"} + } + if r.Scheme == "cas" && r.Integrity == nil { + return &Error{Code: ErrInvalid, Operation: "ref", Detail: "cas ref requires integrity"} + } + if r.ExpiresAtUnixMilli != nil && r.Durability != Ephemeral { + return &Error{Code: ErrInvalid, Operation: "ref", Detail: "only ephemeral refs may expire"} + } + return nil +} + +// Identity is the versioned canonical wire identity of the complete Ref. +func (r Ref) Identity() (string, error) { + if err := r.Validate(); err != nil { + return "", err + } + raw, err := es.EncodeTypedPayload(uint16(WireVersion1), "twilight/artifact/ref", r) + if err != nil { + return "", err + } + return string(raw), nil +} + +// Binding maps a stable BindingID to an immutable Ref (ART-BND-1). +type Binding struct { + ID BindingID `json:"id"` + Ref Ref `json:"ref"` + Digest BindingDigest `json:"digest"` +} + +// DigestBinding covers the domain, BindingID and full RefWireIdentity. +func DigestBinding(id BindingID, ref Ref) (BindingDigest, error) { + identity, err := ref.Identity() + if err != nil { + return "", err + } + raw, err := es.EncodeTypedPayload(uint16(WireVersion1), "twilight/artifact/binding", struct { + ID BindingID `json:"id"` + Identity string `json:"identity"` + }{id, identity}) + if err != nil { + return "", err + } + return BindingDigest(es.DigestBytes(raw)), nil +} + +// NewBinding builds a Binding with its digest. +func NewBinding(id BindingID, ref Ref) (Binding, error) { + if id == "" { + return Binding{}, &Error{Code: ErrInvalid, Operation: "binding", Detail: "empty BindingID"} + } + d, err := DigestBinding(id, ref) + if err != nil { + return Binding{}, err + } + return Binding{ID: id, Ref: ref, Digest: d}, nil +} + +type BindingResolver interface { + ResolveBinding(context.Context, BindingID) (Binding, error) +} + +type BindingStore interface { + BindingResolver + CreateBinding(context.Context, Binding) (Binding, error) + LookupBinding(context.Context, BindingID) (Binding, bool, error) +} + +// MemoryBindingStore is the in-process BindingStore. +type MemoryBindingStore struct { + mu sync.RWMutex + bindings map[BindingID]Binding +} + +func NewMemoryBindingStore() *MemoryBindingStore { + return &MemoryBindingStore{bindings: make(map[BindingID]Binding)} +} + +func (m *MemoryBindingStore) CreateBinding(ctx context.Context, b Binding) (Binding, error) { + if err := ctx.Err(); err != nil { + return Binding{}, err + } + want, err := DigestBinding(b.ID, b.Ref) + if err != nil { + return Binding{}, err + } + if b.Digest != want { + return Binding{}, &Error{Code: ErrInvalid, Operation: "create_binding", Identity: string(b.ID), Detail: "binding digest mismatch"} + } + m.mu.Lock() + defer m.mu.Unlock() + if existing, ok := m.bindings[b.ID]; ok { + if existing.Digest != b.Digest { + return Binding{}, &Error{Code: ErrConflict, Operation: "create_binding", Identity: string(b.ID)} + } + return existing, nil + } + m.bindings[b.ID] = b + return b, nil +} + +func (m *MemoryBindingStore) LookupBinding(ctx context.Context, id BindingID) (Binding, bool, error) { + if err := ctx.Err(); err != nil { + return Binding{}, false, err + } + m.mu.RLock() + defer m.mu.RUnlock() + b, ok := m.bindings[id] + return b, ok, nil +} + +func (m *MemoryBindingStore) ResolveBinding(ctx context.Context, id BindingID) (Binding, error) { + b, ok, err := m.LookupBinding(ctx, id) + if err != nil { + return Binding{}, err + } + if !ok { + return Binding{}, &Error{Code: ErrNotFound, Operation: "resolve_binding", Identity: string(id)} + } + return b, nil +} + +// --- retention ledger -------------------------------------------------------- + +type ClaimOwner struct { + Kind string `json:"kind"` + Authority string `json:"authority"` + Identity string `json:"identity"` +} + +// ClaimOwnerScope selects every owner of one Kind under one Authority. +type ClaimOwnerScope struct { + Kind string + Authority string +} + +type ClaimState string + +const ( + ClaimActive ClaimState = "active" + ClaimReleased ClaimState = "released" +) + +// BindingSet is a canonical, resolved retention set (ART-RET-1). +type BindingSet struct { + BindingIDs []BindingID `json:"bindingIds"` + RefSetDigest RefSetDigest `json:"refSetDigest"` +} + +type RetentionClaim struct { + ID ClaimID `json:"id"` + Owner ClaimOwner `json:"owner"` + BindingSet BindingSet `json:"bindingSet"` + State ClaimState `json:"state"` +} + +// BindingSetBuilder is the only way to construct a BindingSet. +type BindingSetBuilder interface { + Build(context.Context, []BindingID) (BindingSet, error) +} + +// SetBuilder resolves every binding and digests the sorted (ID, Digest) pairs. +type SetBuilder struct{ Resolver BindingResolver } + +func (b SetBuilder) Build(ctx context.Context, ids []BindingID) (BindingSet, error) { + if b.Resolver == nil { + return BindingSet{}, errors.New("artifact: set builder: nil resolver") + } + sorted := SortedUniqueBindingIDs(ids) + type pair struct { + ID BindingID `json:"id"` + Digest BindingDigest `json:"digest"` + } + pairs := make([]pair, 0, len(sorted)) + for _, id := range sorted { + binding, err := b.Resolver.ResolveBinding(ctx, id) + if err != nil { + return BindingSet{}, err + } + if binding.Ref.Durability.Rank() < EventBound.Rank() { + return BindingSet{}, &Error{Code: ErrInvalid, Operation: "build_set", Identity: string(id), Detail: "ephemeral binding cannot be claimed"} + } + pairs = append(pairs, pair{id, binding.Digest}) + } + raw, err := es.EncodeTypedPayload(uint16(WireVersion1), "twilight/artifact/ref-set", pairs) + if err != nil { + return BindingSet{}, err + } + return BindingSet{BindingIDs: sorted, RefSetDigest: RefSetDigest(es.DigestBytes(raw))}, nil +} + +func SortedUniqueBindingIDs(ids []BindingID) []BindingID { + out := append([]BindingID(nil), ids...) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + n := 0 + for i := range out { + if n == 0 || out[i] != out[n-1] { + out[n] = out[i] + n++ + } + } + if n == 0 { + return nil + } + return out[:n] +} + +// RetentionLedger keeps Active/Released claims (ART-RET-2). It persists +// itself; Activate returns only once the claim is durable. +type RetentionLedger interface { + Activate(context.Context, ClaimID, ClaimOwner, BindingSet) (RetentionClaim, error) + LookupClaim(context.Context, ClaimID) (RetentionClaim, bool, error) + ReleaseActive(context.Context, ClaimID) error + // ActiveClaims lists Active claims of every owner in scope, ordered by ClaimID. + ActiveClaims(context.Context, ClaimOwnerScope) ([]RetentionClaim, error) +} + +// OwnerVerifier is supplied by the owner's host: does the owner fact exist? +type OwnerVerifier interface { + OwnerExists(context.Context, ClaimOwner) (bool, error) +} + +// Reconcile releases Active claims in scope whose owner no longer exists +// (ART-RET-3). The caller guarantees no owner write in scope is in flight. +func Reconcile(ctx context.Context, ledger RetentionLedger, scope ClaimOwnerScope, verifier OwnerVerifier) (int, error) { + claims, err := ledger.ActiveClaims(ctx, scope) + if err != nil { + return 0, err + } + released := 0 + for _, c := range claims { + exists, err := verifier.OwnerExists(ctx, c.Owner) + if err != nil { + return released, err + } + if exists { + continue + } + if err := ledger.ReleaseActive(ctx, c.ID); err != nil { + return released, err + } + released++ + } + return released, nil +} + +// MemoryLedger is the in-process RetentionLedger. Builder, when set, rebuilds +// and verifies every incoming set (ART-RET-1). +type MemoryLedger struct { + Builder BindingSetBuilder + mu sync.Mutex + claims map[ClaimID]RetentionClaim +} + +func NewMemoryLedger(builder BindingSetBuilder) *MemoryLedger { + return &MemoryLedger{Builder: builder, claims: make(map[ClaimID]RetentionClaim)} +} + +func (l *MemoryLedger) Activate(ctx context.Context, id ClaimID, owner ClaimOwner, set BindingSet) (RetentionClaim, error) { + if err := ctx.Err(); err != nil { + return RetentionClaim{}, err + } + if id == "" || owner.Kind == "" || owner.Identity == "" { + return RetentionClaim{}, &Error{Code: ErrInvalid, Operation: "activate", Identity: string(id), Detail: "empty claim identity or owner"} + } + if len(set.BindingIDs) == 0 || set.RefSetDigest == "" { + return RetentionClaim{}, &Error{Code: ErrInvalid, Operation: "activate", Identity: string(id), Detail: "empty binding set"} + } + if l.Builder != nil { + rebuilt, err := l.Builder.Build(ctx, set.BindingIDs) + if err != nil { + return RetentionClaim{}, err + } + if !sameSet(rebuilt, set) { + return RetentionClaim{}, &Error{Code: ErrInvalid, Operation: "activate", Identity: string(id), Detail: "binding set does not verify"} + } + } + l.mu.Lock() + defer l.mu.Unlock() + if existing, ok := l.claims[id]; ok { + if existing.State != ClaimActive || existing.Owner != owner || !sameSet(existing.BindingSet, set) { + return RetentionClaim{}, &Error{Code: ErrConflict, Operation: "activate", Identity: string(id)} + } + return existing, nil + } + claim := RetentionClaim{ID: id, Owner: owner, BindingSet: set, State: ClaimActive} + l.claims[id] = claim + return claim, nil +} + +func (l *MemoryLedger) LookupClaim(ctx context.Context, id ClaimID) (RetentionClaim, bool, error) { + if err := ctx.Err(); err != nil { + return RetentionClaim{}, false, err + } + l.mu.Lock() + defer l.mu.Unlock() + c, ok := l.claims[id] + return c, ok, nil +} + +func (l *MemoryLedger) ReleaseActive(ctx context.Context, id ClaimID) error { + if err := ctx.Err(); err != nil { + return err + } + l.mu.Lock() + defer l.mu.Unlock() + c, ok := l.claims[id] + if !ok { + return &Error{Code: ErrNotFound, Operation: "release", Identity: string(id)} + } + c.State = ClaimReleased + l.claims[id] = c + return nil +} + +func (l *MemoryLedger) ActiveClaims(ctx context.Context, scope ClaimOwnerScope) ([]RetentionClaim, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + l.mu.Lock() + defer l.mu.Unlock() + var out []RetentionClaim + for _, c := range l.claims { + if c.State == ClaimActive && c.Owner.Kind == scope.Kind && c.Owner.Authority == scope.Authority { + out = append(out, c) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func sameSet(a, b BindingSet) bool { + if a.RefSetDigest != b.RefSetDigest || len(a.BindingIDs) != len(b.BindingIDs) { + return false + } + for i := range a.BindingIDs { + if a.BindingIDs[i] != b.BindingIDs[i] { + return false + } + } + return true +} + +// --- errors -------------------------------------------------------------------- + +type ErrorCode string + +const ( + ErrInvalid ErrorCode = "invalid" + ErrNotFound ErrorCode = "not_found" + ErrConflict ErrorCode = "conflict" + ErrUnauthorized ErrorCode = "unauthorized" + ErrExpired ErrorCode = "expired" + ErrCorrupt ErrorCode = "corrupt" + ErrUnsupported ErrorCode = "unsupported" + ErrUnavailable ErrorCode = "unavailable" +) + +type Error struct { + Code ErrorCode + Operation string + Identity string + Detail string +} + +func (e *Error) Error() string { + s := fmt.Sprintf("artifact: %s: %s", e.Operation, e.Code) + if e.Identity != "" { + s += " " + e.Identity + } + if e.Detail != "" { + s += ": " + e.Detail + } + return s +} + +func (e *Error) Is(target error) bool { + t, ok := target.(*Error) + return ok && t.Code == e.Code +} diff --git a/agent/es/canonical.go b/agent/es/canonical.go new file mode 100644 index 0000000..fea2c10 --- /dev/null +++ b/agent/es/canonical.go @@ -0,0 +1,60 @@ +// Package es provides domain-neutral event-sourcing protocol mechanisms. +// +// It deliberately does not know Run, Session, Queue, command, fact, or +// Runtime semantics. Domains supply their own event payload codecs and use +// this package for canonical identity, complete-record validation, and fold +// ordering. +package es + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/felinics/twilight/agent/jsonstable" +) + +// Digest is a SHA-256 digest over canonical protocol bytes. +type Digest string + +// CausationID is an opaque cross-domain lineage identifier. Its namespace and +// meaning are owned by the domain or application, never by this package. +type CausationID string + +// Canonicalize validates and canonicalizes external JSON protocol bytes. +func Canonicalize(raw []byte) ([]byte, error) { + return jsonstable.Canonicalize(raw) +} + +// MarshalCanonical encodes a Go protocol value into canonical JSON bytes. +func MarshalCanonical(v any) ([]byte, error) { + return jsonstable.MarshalCanonical(v) +} + +// DigestBytes computes the stable SHA-256 identity of canonical bytes. The +// caller is responsible for canonicalizing structured input first. +func DigestBytes(data []byte) Digest { + sum := sha256.Sum256(data) + return Digest("sha256:" + hex.EncodeToString(sum[:])) +} + +// DigestCanonical canonicalizes v and computes its digest. +func DigestCanonical(v any) (Digest, error) { + body, err := MarshalCanonical(v) + if err != nil { + return "", err + } + return DigestBytes(body), nil +} + +// EncodeTypedPayload renders the common stable digest input for a versioned, +// type-discriminated domain payload. It is intentionally agnostic about which +// schema versions and type names a domain supports. +func EncodeTypedPayload(schemaVersion uint16, typ string, payload any) ([]byte, error) { + canonical, err := MarshalCanonical(payload) + if err != nil { + return nil, err + } + prefix := fmt.Sprintf("v%d:%d:%s:", schemaVersion, len(typ), typ) + return append([]byte(prefix), canonical...), nil +} diff --git a/agent/es/es_test.go b/agent/es/es_test.go new file mode 100644 index 0000000..8b6c3de --- /dev/null +++ b/agent/es/es_test.go @@ -0,0 +1,141 @@ +package es + +import ( + "errors" + "testing" +) + +type testEvent struct { + Schema uint16 + Stream StreamID + Rev Revision + Pos Index + Value int +} + +func testInspector(event testEvent) (EventMetadata, error) { + if event.Value < 0 { + return EventMetadata{}, errors.New("negative payload") + } + return EventMetadata{ + SchemaVersion: event.Schema, + StreamID: event.Stream, + Revision: event.Rev, + Index: event.Pos, + }, nil +} + +func v1(version uint16) bool { return version == 1 } + +func validRecord() Record[testEvent] { + record := Record[testEvent]{ + SchemaVersion: 1, + StreamID: "stream-1", + Revision: 1, + Events: []testEvent{ + {Schema: 1, Stream: "stream-1", Rev: 1, Pos: 0, Value: 2}, + {Schema: 1, Stream: "stream-1", Rev: 1, Pos: 1, Value: 3}, + }, + } + digest, err := DigestRecord(&record) + if err != nil { + panic(err) + } + record.RecordDigest = digest + return record +} + +func TestStandardEventAndRecord(t *testing.T) { + event, err := BuildEvent(1, "stream-1", 1, 0, "added", "cause-1", map[string]string{"x": "y"}) + if err != nil { + t.Fatal(err) + } + record := Record[Event[map[string]string]]{ + SchemaVersion: 1, + StreamID: "stream-1", + Revision: 1, + Events: []Event[map[string]string]{event}, + } + record.RecordDigest, err = DigestRecord(&record) + if err != nil { + t.Fatal(err) + } + inspector := StandardEventInspector[map[string]string](v1) + if err := ValidateRecord(&record, v1, inspector); err != nil { + t.Fatal(err) + } + state, revision, err := FoldStandardRecords(0, "stream-1", []Record[Event[map[string]string]]{record}, v1, inspector, + func(_ uint16, state int, _ Event[map[string]string]) (int, error) { return state + 1, nil }) + if err != nil || state != 1 || revision != 1 { + t.Fatalf("standard fold = state %d revision %d err %v", state, revision, err) + } + record.Events[0].Payload["x"] = "tampered" + if err := ValidateRecord(&record, v1, StandardEventInspector[map[string]string](v1)); err == nil { + t.Fatal("tampered payload accepted") + } +} + +func TestValidateRecord(t *testing.T) { + record := validRecord() + if err := ValidateRecord(&record, v1, testInspector); err != nil { + t.Fatal(err) + } + + record.Events[1].Pos = 2 + if err := ValidateRecord(&record, v1, testInspector); err == nil { + t.Fatal("index gap accepted") + } + + record = validRecord() + record.RecordDigest = "sha256:tampered" + if err := ValidateRecord(&record, v1, testInspector); err == nil { + t.Fatal("tampered aggregate digest accepted") + } +} + +func TestFoldRecords(t *testing.T) { + first := validRecord() + second := Record[testEvent]{ + SchemaVersion: 1, + StreamID: "stream-1", + Revision: 2, + Events: []testEvent{ + {Schema: 1, Stream: "stream-1", Rev: 2, Pos: 0, Value: 5}, + }, + } + views := []RecordView[testEvent]{ + {SchemaVersion: first.SchemaVersion, StreamID: first.StreamID, Revision: first.Revision, Events: first.Events}, + {SchemaVersion: second.SchemaVersion, StreamID: second.StreamID, Revision: second.Revision, Events: second.Events}, + } + state, revision, err := FoldRecords(0, "stream-1", views, v1, testInspector, + func(_ uint16, state int, event testEvent) (int, error) { return state + event.Value, nil }) + if err != nil { + t.Fatal(err) + } + if state != 10 || revision != 2 { + t.Fatalf("fold = state %d revision %d, want 10/2", state, revision) + } + + views[1].Revision = 3 + if _, _, err := FoldRecords(0, "stream-1", views, v1, testInspector, + func(_ uint16, state int, event testEvent) (int, error) { return state + event.Value, nil }); err == nil { + t.Fatal("revision gap accepted") + } +} + +func TestCanonicalDigest(t *testing.T) { + left, err := DigestCanonical(map[string]any{"b": 2, "a": 1}) + if err != nil { + t.Fatal(err) + } + right, err := DigestCanonical(map[string]any{"a": 1, "b": 2}) + if err != nil { + t.Fatal(err) + } + if left != right { + t.Fatalf("canonical digests differ: %s != %s", left, right) + } + if _, err := Canonicalize([]byte(`{"a":1,"a":2}`)); err == nil { + t.Fatal("duplicate JSON object key accepted") + } +} diff --git a/agent/es/fold.go b/agent/es/fold.go new file mode 100644 index 0000000..78c9413 --- /dev/null +++ b/agent/es/fold.go @@ -0,0 +1,71 @@ +package es + +import "fmt" + +// FoldStandardRecords validates standard Record aggregate digests, then folds +// their events in record order. Domains with extra record metadata should +// validate that metadata themselves and use FoldRecords with RecordView. +func FoldStandardRecords[S any, E any]( + initial S, + expectedStream StreamID, + records []Record[E], + supported SchemaSupported, + inspect EventInspector[E], + evolve func(schemaVersion uint16, state S, event E) (S, error), +) (S, Revision, error) { + views := make([]RecordView[E], len(records)) + for i := range records { + if err := ValidateRecord(&records[i], supported, inspect); err != nil { + return initial, 0, err + } + views[i] = RecordView[E]{ + SchemaVersion: records[i].SchemaVersion, + StreamID: records[i].StreamID, + Revision: records[i].Revision, + Events: records[i].Events, + } + } + return FoldRecords(initial, expectedStream, views, supported, inspect, evolve) +} + +// FoldRecords validates a contiguous record sequence and folds each event in +// record order. It is purely mechanical: it never decides new events and does +// no IO. Domains provide an initial state, schema-aware event validation, and +// their own versioned evolve function. +func FoldRecords[S any, E any]( + initial S, + expectedStream StreamID, + records []RecordView[E], + supported SchemaSupported, + inspect EventInspector[E], + evolve func(schemaVersion uint16, state S, event E) (S, error), +) (S, Revision, error) { + state := initial + if expectedStream == "" { + return initial, 0, fmt.Errorf("es: fold: empty expected stream") + } + if evolve == nil { + return initial, 0, fmt.Errorf("es: fold: nil evolve function") + } + var revision Revision + for i, record := range records { + if err := ValidateRecordView(record, supported, inspect); err != nil { + return initial, 0, err + } + if record.StreamID != expectedStream { + return initial, 0, fmt.Errorf("es: fold: record %d stream %q does not match expected stream %q", i, record.StreamID, expectedStream) + } + if record.Revision != revision+1 { + return initial, 0, fmt.Errorf("es: fold: gap at revision %d (expected %d)", record.Revision, revision+1) + } + for _, event := range record.Events { + var err error + state, err = evolve(record.SchemaVersion, state, event) + if err != nil { + return initial, 0, err + } + } + revision = record.Revision + } + return state, revision, nil +} diff --git a/agent/es/record.go b/agent/es/record.go new file mode 100644 index 0000000..3140efe --- /dev/null +++ b/agent/es/record.go @@ -0,0 +1,216 @@ +package es + +import ( + "errors" + "fmt" +) + +// StreamID identifies one append-only event stream. +type StreamID string + +// Revision identifies one accepted atomic record in a stream. Revision zero +// is reserved for the domain's immutable initial state/header. +type Revision uint64 + +// Index is an event's zero-based position within one atomic record. +type Index uint16 + +// Event is the standard domain-neutral event envelope. Domains that require +// additional fields may use their own envelope and expose it through +// EventMetadata instead. +type Event[P any] struct { + SchemaVersion uint16 `json:"schemaVersion"` + StreamID StreamID `json:"streamId"` + Revision Revision `json:"revision"` + Index Index `json:"index"` + Type string `json:"type"` + CausationID CausationID `json:"causationId,omitempty"` + Payload P `json:"payload"` + PayloadDigest Digest `json:"payloadDigest"` +} + +// BuildEvent constructs the standard event envelope and binds the payload's +// canonical digest. The caller owns event Type and schema selection. +func BuildEvent[P any](schemaVersion uint16, streamID StreamID, revision Revision, index Index, typ string, causationID CausationID, payload P) (Event[P], error) { + digest, err := DigestCanonical(payload) + if err != nil { + return Event[P]{}, err + } + return Event[P]{ + SchemaVersion: schemaVersion, + StreamID: streamID, + Revision: revision, + Index: index, + Type: typ, + CausationID: causationID, + Payload: payload, + PayloadDigest: digest, + }, nil +} + +// ValidateEvent validates a standard event envelope and payload digest. +func ValidateEvent[P any](event Event[P], supported SchemaSupported) error { + if event.StreamID == "" || event.Revision == 0 || event.Type == "" { + return errors.New("es: event: missing identity") + } + if supported == nil || !supported(event.SchemaVersion) { + return fmt.Errorf("es: event: unsupported schema version %d", event.SchemaVersion) + } + if event.PayloadDigest == "" { + return errors.New("es: event: missing payload digest") + } + want, err := DigestCanonical(event.Payload) + if err != nil { + return err + } + if event.PayloadDigest != want { + return fmt.Errorf("es: event: payload digest mismatch at revision %d index %d", event.Revision, event.Index) + } + return nil +} + +// InspectEvent is the EventInspector adapter for the standard Event envelope. +func InspectEvent[P any](event Event[P]) (EventMetadata, error) { + return EventMetadata{ + SchemaVersion: event.SchemaVersion, + StreamID: event.StreamID, + Revision: event.Revision, + Index: event.Index, + }, nil +} + +// StandardEventInspector validates the standard Event payload before exposing +// its generic record metadata. +func StandardEventInspector[P any](supported SchemaSupported) EventInspector[Event[P]] { + return func(event Event[P]) (EventMetadata, error) { + if err := ValidateEvent(event, supported); err != nil { + return EventMetadata{}, err + } + return InspectEvent(event) + } +} + +// Record is the standard complete atomic event group. Domains with additional +// record metadata (for example a Run command identity) retain their own +// record type and adapt it to RecordView for validation/folding. +type Record[E any] struct { + SchemaVersion uint16 `json:"schemaVersion"` + StreamID StreamID `json:"streamId"` + Revision Revision `json:"revision"` + Events []E `json:"events"` + RecordDigest Digest `json:"recordDigest"` +} + +type recordDigestBody[E any] struct { + SchemaVersion uint16 `json:"schemaVersion"` + StreamID StreamID `json:"streamId"` + Revision Revision `json:"revision"` + Events []E `json:"events"` +} + +// DigestRecord computes the standard Record aggregate digest. Domains with +// additional record metadata should digest their own complete body with +// DigestCanonical, then use RecordView for the shared completeness checks. +func DigestRecord[E any](record *Record[E]) (Digest, error) { + if record == nil { + return "", errors.New("es: record: nil record") + } + return DigestCanonical(recordDigestBody[E]{ + SchemaVersion: record.SchemaVersion, + StreamID: record.StreamID, + Revision: record.Revision, + Events: record.Events, + }) +} + +// EventMetadata is the identity portion es needs to establish complete event +// groups. Payload/schema validation remains a domain responsibility. +type EventMetadata struct { + SchemaVersion uint16 + StreamID StreamID + Revision Revision + Index Index +} + +// RecordView adapts a domain record to es mechanics without requiring that the +// domain adopt es's on-wire field names or give up domain-specific metadata. +type RecordView[E any] struct { + SchemaVersion uint16 + StreamID StreamID + Revision Revision + Events []E +} + +// EventInspector extracts generic event metadata and performs any domain +// payload validation needed before an event is folded. +type EventInspector[E any] func(E) (EventMetadata, error) + +// SchemaSupported reports whether a record/event schema can be folded. +type SchemaSupported func(uint16) bool + +// ValidateRecord validates a standard Record, including its aggregate digest. +func ValidateRecord[E any](record *Record[E], supported SchemaSupported, inspect EventInspector[E]) error { + if record == nil { + return errors.New("es: record: nil record") + } + if err := ValidateRecordView(RecordView[E]{ + SchemaVersion: record.SchemaVersion, + StreamID: record.StreamID, + Revision: record.Revision, + Events: record.Events, + }, supported, inspect); err != nil { + return err + } + if record.RecordDigest == "" { + return errors.New("es: record: missing digest") + } + want, err := DigestRecord(record) + if err != nil { + return err + } + if record.RecordDigest != want { + return fmt.Errorf("es: record: digest mismatch at revision %d", record.Revision) + } + return nil +} + +// ValidateRecordView proves that a record contains a complete, ordered event +// group for exactly one stream revision. It intentionally does not interpret +// event Type, payload, command identity, or record digest; those are domain +// protocol fields validated by the supplied inspector and domain adapter. +func ValidateRecordView[E any](record RecordView[E], supported SchemaSupported, inspect EventInspector[E]) error { + if record.StreamID == "" || record.Revision == 0 { + return errors.New("es: record: missing stream identity") + } + if supported == nil || !supported(record.SchemaVersion) { + return fmt.Errorf("es: record: unsupported schema version %d", record.SchemaVersion) + } + if inspect == nil { + return errors.New("es: record: nil event inspector") + } + if len(record.Events) == 0 { + return fmt.Errorf("es: record: revision %d has no events", record.Revision) + } + if len(record.Events) > int(^uint16(0))+1 { + return fmt.Errorf("es: record: revision %d event group too large: %d", record.Revision, len(record.Events)) + } + for i, event := range record.Events { + meta, err := inspect(event) + if err != nil { + return fmt.Errorf("es: record: revision %d event %d: %w", record.Revision, i, err) + } + if meta.SchemaVersion != record.SchemaVersion { + return fmt.Errorf("es: record: event %d schema version %d does not match record %d", i, meta.SchemaVersion, record.SchemaVersion) + } + if meta.StreamID != record.StreamID { + return fmt.Errorf("es: record: event %d stream %q does not match record stream %q", i, meta.StreamID, record.StreamID) + } + if meta.Revision != record.Revision { + return fmt.Errorf("es: record: event %d revision %d does not match record revision %d", i, meta.Revision, record.Revision) + } + if meta.Index != Index(i) { + return fmt.Errorf("es: record: revision %d index %d has event index %d", record.Revision, i, meta.Index) + } + } + return nil +} diff --git a/agent/jsonstable/jsonstable.go b/agent/jsonstable/jsonstable.go new file mode 100644 index 0000000..f5c22a1 --- /dev/null +++ b/agent/jsonstable/jsonstable.go @@ -0,0 +1,215 @@ +// Package jsonstable provides immutable RFC 8785 (JCS) JSON values for agent +// wire protocols. External bytes are parsed and canonicalized once at the +// boundary; after that Value is safe to store in commands, facts, and +// MachineState. +package jsonstable + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "unicode/utf8" + + "github.com/gowebpki/jcs" +) + +// Value is an immutable canonical JSON value. The zero value represents an +// absent value for omitzero fields and marshals as JSON null when required. +type Value struct { + raw []byte +} + +// Parse validates raw JSON and stores its RFC 8785 canonical representation. +// A nil slice returns the zero Value; an empty but non-nil slice is invalid +// JSON. +// +// JCS gives every I-JSON value one cross-language representation. JSON +// numbers therefore have IEEE-754 binary64 semantics. Exact identifiers or +// arbitrary-precision quantities must be represented as JSON strings, not +// JSON numbers. +func Parse(raw []byte) (Value, error) { + if raw == nil { + return Value{}, nil + } + canonical, err := Canonicalize(raw) + if err != nil { + return Value{}, err + } + return Value{raw: append([]byte(nil), canonical...)}, nil +} + +// MustParse is a convenience for tests and package-level constants. +func MustParse(raw string) Value { + v, err := Parse([]byte(raw)) + if err != nil { + panic(err) + } + return v +} + +// FromValue marshals a Go JSON-shaped value and stores its canonical form. +func FromValue(v any) (Value, error) { + if existing, ok := v.(Value); ok { + return existing, nil + } + if raw, ok := v.(json.RawMessage); ok { + return Parse(raw) + } + raw, err := json.Marshal(v) + if err != nil { + return Value{}, err + } + return Parse(raw) +} + +// Bytes returns a detached canonical byte slice. The zero Value returns null. +func (v Value) Bytes() []byte { + if len(v.raw) == 0 { + return []byte("null") + } + return append([]byte(nil), v.raw...) +} + +// RawMessage returns a detached json.RawMessage view of the canonical bytes. +func (v Value) RawMessage() json.RawMessage { + return json.RawMessage(v.Bytes()) +} + +func (v Value) String() string { return string(v.Bytes()) } + +// IsZero reports whether v is absent. It is used by encoding/json's omitzero +// tag; a present JSON null is not zero because its raw bytes are "null". +func (v Value) IsZero() bool { return len(v.raw) == 0 } + +func (v Value) Equal(other Value) bool { return bytes.Equal(v.Bytes(), other.Bytes()) } + +func (v Value) Decode(dst any) error { + dec := json.NewDecoder(bytes.NewReader(v.Bytes())) + dec.UseNumber() + return dec.Decode(dst) +} + +func (v Value) Any() (any, error) { + dec := json.NewDecoder(bytes.NewReader(v.Bytes())) + dec.UseNumber() + var out any + if err := dec.Decode(&out); err != nil { + return nil, err + } + return out, nil +} + +func (v Value) MarshalJSON() ([]byte, error) { return v.Bytes(), nil } + +func (v *Value) UnmarshalJSON(raw []byte) error { + parsed, err := Parse(raw) + if err != nil { + return err + } + *v = parsed + return nil +} + +// Canonicalize transforms JSON into RFC 8785 JSON Canonicalization Scheme +// (JCS) bytes. It delegates parsing and ECMAScript number formatting to the +// RFC 8785 reference-lineage implementation rather than encoding/json or a +// local formatter, so a PostgreSQL JSONB round trip remains digest-stable +// after canonicalization. +func Canonicalize(raw []byte) ([]byte, error) { + if !utf8.Valid(raw) { + return nil, errors.New("agent: canonical: invalid UTF-8 input") + } + if err := rejectEscapedLoneSurrogates(raw); err != nil { + return nil, err + } + canonical, err := jcs.Transform(raw) + if err != nil { + return nil, fmt.Errorf("agent: canonical: %w", err) + } + return canonical, nil +} + +// rejectEscapedLoneSurrogates prevents the JCS parser's Unicode replacement +// behavior from merging distinct invalid wire values before they reach a +// digest. It validates only escaped UTF-16 structure; full JSON syntax remains +// the responsibility of the RFC 8785 parser. +func rejectEscapedLoneSurrogates(raw []byte) error { + for i := 0; i < len(raw); i++ { + if raw[i] != '"' { + continue + } + i++ + for i < len(raw) { + switch raw[i] { + case '"': + goto nextToken + case '\\': + if i+1 >= len(raw) { + return nil // let the JCS parser report syntax. + } + if raw[i+1] != 'u' && raw[i+1] != 'U' { + i += 2 + continue + } + if i+6 > len(raw) { + return nil // let the JCS parser report syntax. + } + code, ok := parseHex4(raw[i+2 : i+6]) + if !ok { + return nil // let the JCS parser report syntax. + } + switch { + case 0xd800 <= code && code <= 0xdbff: + if i+12 > len(raw) || raw[i+6] != '\\' || (raw[i+7] != 'u' && raw[i+7] != 'U') { + return errors.New("agent: canonical: escaped lone surrogate") + } + low, ok := parseHex4(raw[i+8 : i+12]) + if !ok || low < 0xdc00 || low > 0xdfff { + return errors.New("agent: canonical: escaped lone surrogate") + } + i += 12 + case 0xdc00 <= code && code <= 0xdfff: + return errors.New("agent: canonical: escaped lone surrogate") + default: + i += 6 + } + default: + i++ + } + } + nextToken: + } + return nil +} + +func parseHex4(raw []byte) (rune, bool) { + if len(raw) != 4 { + return 0, false + } + var n rune + for _, b := range raw { + n <<= 4 + switch { + case '0' <= b && b <= '9': + n += rune(b - '0') + case 'a' <= b && b <= 'f': + n += rune(b-'a') + 10 + case 'A' <= b && b <= 'F': + n += rune(b-'A') + 10 + default: + return 0, false + } + } + return n, true +} + +// MarshalCanonical marshals a Go value and canonicalizes its JSON wire form. +// This is the single path from protocol values to digest input. +func MarshalCanonical(v any) ([]byte, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("agent: canonical: %w", err) + } + return Canonicalize(raw) +} diff --git a/agent/ref/agent.go b/agent/ref/agent.go new file mode 100644 index 0000000..5d03a1f --- /dev/null +++ b/agent/ref/agent.go @@ -0,0 +1,299 @@ +package ref + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/turn" + "github.com/felinics/twilight/sdk" +) + +type PublicTool struct { + Ref run.ToolRef `json:"ref"` + Definition run.ToolDefinition `json:"definition"` + Policy run.ResponsePolicy `json:"policy"` +} + +// Profile is the public configuration of an Agent (REF-BND-1). Credentials +// and clients stay in process; the Session records turn.ProfileRef{ID, Digest}. +type Profile struct { + SchemaVersion uint16 `json:"schemaVersion"` + Model run.ModelRef `json:"model"` + Tools []PublicTool `json:"tools,omitempty"` + Streaming bool `json:"streaming,omitempty"` + // SystemPrompt tunes the conversation. It is outside the profile digest, + // so editing it never orphans a resumable Turn. + SystemPrompt string `json:"systemPrompt,omitempty"` +} + +// DigestProfile covers the fields that change replay correctness: +// SchemaVersion, Model, Tools and Streaming. SystemPrompt is excluded. +func DigestProfile(p *Profile) (es.Digest, error) { + body := struct { + SchemaVersion uint16 `json:"schemaVersion"` + Model run.ModelRef `json:"model"` + Tools []PublicTool `json:"tools,omitempty"` + Streaming bool `json:"streaming,omitempty"` + }{p.SchemaVersion, p.Model, p.Tools, p.Streaming} + raw, err := es.EncodeTypedPayload(1, "twilight/ref/profile", body) + if err != nil { + return "", err + } + return es.DigestBytes(raw), nil +} + +// ToolSpecs derives the frozen ToolSpecs and provider definitions of p, in +// order (REF-PLN-4). +func (p *Profile) ToolSpecs() ([]run.ToolSpec, []sdk.ToolDefinition, error) { + specs := make([]run.ToolSpec, 0, len(p.Tools)) + defs := make([]sdk.ToolDefinition, 0, len(p.Tools)) + for _, t := range p.Tools { + d, err := run.ProtocolV1().DigestToolDefinition(t.Definition) + if err != nil { + return nil, nil, err + } + specs = append(specs, run.ToolSpec{Ref: t.Ref, Name: t.Definition.Name, DefinitionDigest: d, Policy: t.Policy}) + defs = append(defs, t.Definition.SDK()) + } + return specs, defs, nil +} + +// Agent is one registrable execution configuration: the durable Profile plus +// the live capabilities that resolve it. Implement it directly for custom +// catalogs, or build the common shape with NewAgent. +type Agent interface { + Profile() Profile + ResolveModel(run.ModelRef) (loop.ModelInvoker, error) + ResolveTool(run.ToolRef) (loop.ExecutableTool, error) +} + +// PolicyProvider is optional: an Agent that tunes the Loop's execution policy. +type PolicyProvider interface { + Policy() loop.ExecutionPolicy +} + +type agentConfig struct { + tools []loop.ExecutableTool + systemPrompt string + streaming bool + policy loop.ExecutionPolicy +} + +type AgentOption func(*agentConfig) + +// WithTool adds one executable tool; its frozen definition and response +// policy enter the Profile. +func WithTool(t loop.ExecutableTool) AgentOption { + return func(c *agentConfig) { c.tools = append(c.tools, t) } +} + +func WithSystemPrompt(s string) AgentOption { + return func(c *agentConfig) { c.systemPrompt = s } +} + +func WithStreaming(on bool) AgentOption { + return func(c *agentConfig) { c.streaming = on } +} + +func WithPolicy(p loop.ExecutionPolicy) AgentOption { + return func(c *agentConfig) { c.policy = p } +} + +// NewAgent builds the common one-model Agent: the Profile is assembled from +// the model ref and the tools' frozen definitions. +func NewAgent(model run.ModelRef, invoker loop.ModelInvoker, opts ...AgentOption) (Agent, error) { + if model == "" || invoker == nil { + return nil, errors.New("ref: agent requires a model ref and an invoker") + } + var cfg agentConfig + for _, opt := range opts { + opt(&cfg) + } + a := &builtAgent{ + profile: Profile{SchemaVersion: 1, Model: model, Streaming: cfg.streaming, SystemPrompt: cfg.systemPrompt}, + invoker: invoker, + tools: make(map[run.ToolRef]loop.ExecutableTool, len(cfg.tools)), + policy: cfg.policy, + } + for _, t := range cfg.tools { + if _, dup := a.tools[t.Ref()]; dup { + return nil, fmt.Errorf("ref: duplicate tool %q", t.Ref()) + } + def, err := run.FreezeToolDefinition(t.Definition()) + if err != nil { + return nil, err + } + a.profile.Tools = append(a.profile.Tools, PublicTool{Ref: t.Ref(), Definition: def, Policy: t.ResponsePolicy()}) + a.tools[t.Ref()] = t + } + return a, nil +} + +type builtAgent struct { + profile Profile + invoker loop.ModelInvoker + tools map[run.ToolRef]loop.ExecutableTool + policy loop.ExecutionPolicy +} + +func (a *builtAgent) Profile() Profile { return a.profile } + +func (a *builtAgent) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { return a.invoker, nil } + +func (a *builtAgent) ResolveTool(r run.ToolRef) (loop.ExecutableTool, error) { + t, ok := a.tools[r] + if !ok { + return nil, fmt.Errorf("ref: unknown tool %q", r) + } + return t, nil +} + +func (a *builtAgent) Policy() loop.ExecutionPolicy { return a.policy } + +// ErrProfileUnavailable reports that the persisted profile cannot be resolved +// by this process (REF-BND-2). +var ErrProfileUnavailable = errors.New("ref: profile_unavailable") + +// ErrAlreadyDriving is how a RunDriver reports that another local driver +// already drives the Run: the commit (if any) landed and the running driver +// carries it forward. The host turns it into a successful Result with +// ResumeAlreadyDriving, not an error. +var ErrAlreadyDriving = errors.New("ref: already_driving") + +// ResumeAlreadyDriving extends the turn disposition vocabulary for hosts: the +// inputs (if any) are committed and another local driver of the same Run +// carries them forward. The Coordinator itself never produces it. +const ResumeAlreadyDriving turn.ResumeDisposition = "already_driving" + +type DriveRequest struct { + Ref turn.TurnRef + RunID run.RunID +} + +// RunDriver drives one Run to its next quiescent point; the reference driver +// wraps loop.Run (REF-DRV-1). A second local driver of the same Run reports +// ErrAlreadyDriving instead of driving. +type RunDriver interface { + Drive(context.Context, DriveRequest) error +} + +// ProfileRegistry resolves a persisted ProfileRef to a live driver. +type ProfileRegistry interface { + Resolve(turn.ProfileRef) (RunDriver, error) +} + +// Agents is the in-process ProfileRegistry (REF-BND-2). Register builds +// one long-lived Loop per registration, so every drive of a profile shares +// the already-driving guard: a second local driver of a running Run reports +// ErrAlreadyDriving instead of racing the first. +type Agents struct { + runtime run.Runtime + projections ProjectionSource + sink loop.EventSink + + mu sync.RWMutex + byID map[turn.ProfileID]registeredAgent +} + +type registeredAgent struct { + agent Agent + driver RunDriver +} + +// ProjectionSource is what the planner reads context from. +type ProjectionSource interface { + Load(ctx context.Context, sid session.SessionID, id extensionProjectionID, v extensionProjectionVersion) (any, session.Head, error) +} + +func NewAgents(runtime run.Runtime, projections ProjectionSource, sink loop.EventSink) *Agents { + return &Agents{runtime: runtime, projections: projections, sink: sink, byID: map[turn.ProfileID]registeredAgent{}} +} + +// Register stores an agent, builds its driver and returns the ref the Session +// records. +func (r *Agents) Register(id turn.ProfileID, agent Agent) (turn.ProfileRef, error) { + if id == "" || agent == nil { + return turn.ProfileRef{}, errors.New("ref: register requires an id and an agent") + } + p := agent.Profile() + if p.Model == "" || p.SchemaVersion == 0 { + return turn.ProfileRef{}, errors.New("ref: profile requires model and schemaVersion") + } + digest, err := DigestProfile(&p) + if err != nil { + return turn.ProfileRef{}, err + } + var policy loop.ExecutionPolicy + if pp, ok := agent.(PolicyProvider); ok { + policy = pp.Policy() + } + planner := &ContextPlanner{Projections: r.projections, Profile: p} + l, err := loop.New(agent, agent, planner, policy, p.Streaming) + if err != nil { + return turn.ProfileRef{}, err + } + r.mu.Lock() + r.byID[id] = registeredAgent{agent: agent, driver: loopDriver{loop: l, runtime: r.runtime, sink: r.sink}} + r.mu.Unlock() + return turn.ProfileRef{ID: id, Digest: digest}, nil +} + +// Resolve returns the registration's driver when the ref's digest matches the +// agent's current Profile (REF-BND-2). +func (r *Agents) Resolve(ref turn.ProfileRef) (RunDriver, error) { + reg, err := r.lookup(ref) + if err != nil { + return nil, err + } + return reg.driver, nil +} + +// Agent returns the registered agent behind a ref, digest-checked like +// Resolve; hosts use it for profile-model calls outside any Run (REF-CKP-1). +func (r *Agents) Agent(ref turn.ProfileRef) (Agent, error) { + reg, err := r.lookup(ref) + if err != nil { + return nil, err + } + return reg.agent, nil +} + +func (r *Agents) lookup(ref turn.ProfileRef) (registeredAgent, error) { + r.mu.RLock() + reg, ok := r.byID[ref.ID] + r.mu.RUnlock() + if !ok { + return registeredAgent{}, fmt.Errorf("ref: unknown profile %s", ref.ID) + } + p := reg.agent.Profile() + digest, err := DigestProfile(&p) + if err != nil { + return registeredAgent{}, err + } + if digest != ref.Digest { + return registeredAgent{}, fmt.Errorf("ref: profile %s digest mismatch", ref.ID) + } + return reg, nil +} + +// loopDriver is REF-DRV-1: Drive is loop.Run. A concurrent local driver of +// the same Run is reported as ErrAlreadyDriving, not as a failure. +type loopDriver struct { + loop *loop.Loop + runtime run.Runtime + sink loop.EventSink +} + +func (d loopDriver) Drive(ctx context.Context, req DriveRequest) error { + _, err := d.loop.Run(ctx, d.runtime, req.Ref.SessionID, req.RunID, d.sink) + if errors.Is(err, loop.ErrRunAlreadyRunning) { + return fmt.Errorf("%w: %v", ErrAlreadyDriving, err) + } + return err +} diff --git a/agent/ref/app_module_test.go b/agent/ref/app_module_test.go new file mode 100644 index 0000000..cf976fc --- /dev/null +++ b/agent/ref/app_module_test.go @@ -0,0 +1,158 @@ +package ref_test + +import ( + "context" + "strings" + "testing" + + "github.com/felinics/twilight/agent/ref" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + "github.com/felinics/twilight/agent/turn" +) + +// The example application module: source "example", module "audit". It records +// audit notes as its own durable events and folds a trail projection over its +// notes plus the chatlog inputs it Requires. +const ( + auditSource extension.SourceID = "example" + auditID extension.ModuleID = "audit" + auditTrail extension.ProjectionID = "example/audit/trail" +) + +var auditNoteType = extension.ModulePrefix(auditSource, auditID) + "note" + +type auditNote struct { + InputID string `json:"inputId"` + Text string `json:"text"` +} + +type auditState struct { + Inputs []string `json:"inputs"` + Notes []string `json:"notes"` +} + +var auditModule = extension.ModuleDescriptor{ + Source: auditSource, + ID: auditID, + Requires: []extension.ModuleRequirement{{ + Source: extension.SourceTwilight, Module: chatlog.ModuleID, + Events: map[session.EventType][]extension.PayloadVersion{chatlog.TypeInputSubmitted: {1}}, + }}, + Events: []extension.EventDefinition{{ + Type: auditNoteType, Current: 1, + Codecs: map[extension.PayloadVersion]extension.PayloadCodec{1: extension.JSONCodec[auditNote]{}}, + }}, + Projections: []extension.ProjectionDefinition{{ + ID: auditTrail, Version: 1, + Consumes: []session.EventType{auditNoteType, chatlog.TypeInputSubmitted}, + Initial: func() (any, error) { return auditState{}, nil }, + Apply: func(state any, e extension.DecodedEvent) (any, error) { + s := state.(auditState) + switch v := e.Value.(type) { + case auditNote: + s.Notes = append(append([]string(nil), s.Notes...), v.Text) + case chatlog.InputSubmittedPayload: + s.Inputs = append(append([]string(nil), s.Inputs...), string(v.InputID)) + } + return s, nil + }, + StateCodec: extension.JSONStateCodec[auditState]{}, + }}, +} + +// An application module registered through Options.Modules writes its own +// events into the Session stream and folds its own projection, while the +// first-party projections skip its rows as out-of-scope (EXT-REG-1, EXT-PRJ-2). +func TestAppModuleSharesTheSessionStream(t *testing.T) { + ctx := context.Background() + m, err := ref.New(ref.Options{Modules: []extension.ModuleDescriptor{auditModule}}) + if err != nil { + t.Fatal(err) + } + const sid session.SessionID = "s-app" + if err := m.EnsureSession(ctx, sid); err != nil { + t.Fatal(err) + } + agent, err := ref.NewAgent("m-1", &scriptedRequests{}) + if err != nil { + t.Fatal(err) + } + profile, err := m.Agents.Register("b1", agent) + if err != nil { + t.Fatal(err) + } + + in, err := m.SubmitInput(ctx, sid, "in-1", "hello") + if err != nil { + t.Fatal(err) + } + // The app module commits its own event through the shared Writer. + w, err := m.Writers.Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + res, err := w.Commit(ctx, func(extension.View) (*extension.SemanticGroup, error) { + return &extension.SemanticGroup{CommitID: "audit/n1", Events: []extension.TypedEvent{{ + Type: auditNoteType, RecordedAtUnixMilli: 1, Value: auditNote{InputID: "in-1", Text: "flagged"}, + }}}, nil + }) + if err != nil || res.Outcome != extension.CommitApplied { + t.Fatalf("audit commit = %+v %v", res, err) + } + if _, err := m.Coordinator.Start(ctx, turn.StartRequest{Ref: turn.TurnRef{SessionID: sid, TurnID: "t1"}, + Inputs: []run.AgentInput{in}, Profile: profile, Companion: turn.CompanionV1Version}); err != nil { + t.Fatal(err) + } + if _, err := m.Drive(ctx, turn.TurnRef{SessionID: sid, TurnID: "t1"}); err != nil { + t.Fatal(err) + } + + // The app projection folded both its own event and the chatlog input. + state, _, err := m.Projection(ctx, sid, auditTrail, 1) + if err != nil { + t.Fatal(err) + } + trail := state.(auditState) + if len(trail.Inputs) != 1 || trail.Inputs[0] != "in-1" || len(trail.Notes) != 1 || trail.Notes[0] != "flagged" { + t.Fatalf("audit trail = %+v", trail) + } + + // First-party projections fold across the app rows untouched. + chat, err := m.ChatlogSurface(ctx, sid) + if err != nil { + t.Fatal(err) + } + if got := chat.Inputs["in-1"].Status; got != chatlog.InputDelivered { + t.Fatalf("input status = %s", got) + } + tsurf, err := m.TurnSurface(ctx, sid) + if err != nil { + t.Fatal(err) + } + if tsurf.Turns["t1"].Status != turn.TurnCompleted { + t.Fatalf("turn status = %s", tsurf.Turns["t1"].Status) + } + + // Both sources coexist in one stream. + page, err := m.Store.Read(ctx, session.ReadRequest{SessionID: sid}) + if err != nil { + t.Fatal(err) + } + var app, core int + for _, e := range page.Events { + switch { + case strings.HasPrefix(string(e.Type), string(extension.ModulePrefix(auditSource, auditID))): + app++ + case strings.HasPrefix(string(e.Type), "twilight/"): + core++ + default: + t.Fatalf("unexpected type %s", e.Type) + } + } + if app != 1 || core < 3 { + t.Fatalf("stream mix: app=%d core=%d", app, core) + } +} diff --git a/agent/ref/assembly.go b/agent/ref/assembly.go new file mode 100644 index 0000000..bad0230 --- /dev/null +++ b/agent/ref/assembly.go @@ -0,0 +1,287 @@ +package ref + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/felinics/twilight/agent/artifact" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" + "github.com/felinics/twilight/agent/turn" +) + +// Options tunes the Memory assembly. +type Options struct { + // Ownership configures the Session Writer: Takeover lets this assembly + // supersede a previous owner, whose writer is then fenced by its Epoch. + Ownership session.OpenOptions + Now func() time.Time + // Frozen shares request bodies between "processes" in tests; nil creates one. + Frozen run.FrozenValueStore + // Store shares the Session store between assemblies; nil creates one. + Store session.Store + // Ledger shares the retention ledger between assemblies; nil creates one. + Ledger artifact.RetentionLedger + // BindingStore shares bindings between assemblies; nil creates one. + BindingStore *artifact.MemoryBindingStore + // Sink receives Loop observations; nil discards them. + Sink loop.EventSink + // Modules are application modules registered after the first-party three; + // each must carry its own non-twilight Source (EXT-REG-1). + Modules []extension.ModuleDescriptor +} + +// Memory is the fully wired in-process agent (REF 5). One Memory is one +// owner process: its Writers hold the Session ownership. +type Memory struct { + Store session.Store + Registry *extension.Registry + Writers extension.Writers + Agents *Agents + Runtime *runmod.Runtime + Coordinator *turn.Coordinator + BindingStore *artifact.MemoryBindingStore + Ledger artifact.RetentionLedger + now func() time.Time +} + +// New assembles store, registry, writers, runtime and coordinator over the +// three first-party modules. +func New(opts Options) (*Memory, error) { + store := opts.Store + if store == nil { + store = session.NewMemoryStore() + } + modules := append([]extension.ModuleDescriptor{chatlog.Module, runmod.Module, turn.Module}, opts.Modules...) + registry, err := extension.BuildRegistry(session.ProtocolVersion1, modules...) + if err != nil { + return nil, err + } + bindings := opts.BindingStore + if bindings == nil { + bindings = artifact.NewMemoryBindingStore() + } + ledger := opts.Ledger + if ledger == nil { + ledger = artifact.NewMemoryLedger(artifact.SetBuilder{Resolver: bindings}) + } + writers := extension.NewWriters(store, registry, extension.Admission{Bindings: bindings, Ledger: ledger}, opts.Ownership) + now := opts.Now + if now == nil { + now = time.Now + } + runtime, err := runmod.NewRuntime(runmod.Config{ + Writers: writers, Registry: registry, Store: store, + Frozen: opts.Frozen, Companion: turn.CompanionV1{}, Now: now, + }) + if err != nil { + return nil, err + } + m := &Memory{Store: store, Registry: registry, Writers: writers, Runtime: runtime, BindingStore: bindings, Ledger: ledger, now: now} + m.Agents = NewAgents(runtime, writersProjections{writers}, opts.Sink) + m.Coordinator = &turn.Coordinator{Writers: writers, Runtime: runtime, Now: now} + return m, nil +} + +// Drive is REF-DRV-1, the host side the Coordinator no longer carries: while +// the Turn is active, resolve its recorded profile and drive the active +// attempt to the next quiescent point, then read the committed Status. The +// caller's ctx bounds the drive, so cancellation is a host decision. A +// concurrent local driver of the same Run yields ResumeAlreadyDriving. +func (m *Memory) Drive(ctx context.Context, ref turn.TurnRef) (turn.TurnResponse, error) { + surface, err := m.TurnSurface(ctx, ref.SessionID) + if err != nil { + return turn.TurnResponse{}, err + } + view, ok := surface.Turns[ref.TurnID] + if !ok { + return turn.TurnResponse{}, fmt.Errorf("%w: unknown turn %s", turn.ErrConflict, ref.TurnID) + } + if view.Status == turn.TurnActive { + driver, err := m.Agents.Resolve(view.Profile) + if err != nil { + return turn.TurnResponse{}, fmt.Errorf("%w: %v", ErrProfileUnavailable, err) + } + if err := driver.Drive(ctx, DriveRequest{Ref: ref, RunID: view.ActiveRun}); err != nil { + if errors.Is(err, ErrAlreadyDriving) { + resp, rerr := m.Coordinator.Status(ctx, ref) + if rerr != nil { + return turn.TurnResponse{}, rerr + } + resp.Disposition = ResumeAlreadyDriving + return resp, nil + } + return turn.TurnResponse{}, err + } + } + return m.Coordinator.Status(ctx, ref) +} + +// writersProjections reads projections through the Session's Writer. +type writersProjections struct{ writers extension.Writers } + +func (p writersProjections) Load(ctx context.Context, sid session.SessionID, id extensionProjectionID, v extensionProjectionVersion) (any, session.Head, error) { + w, err := p.writers.Writer(ctx, sid) + if err != nil { + return nil, session.Head{}, err + } + return w.Projections().Load(ctx, sid, id, v) +} + +// CreateSession creates the Session stream. +func (m *Memory) CreateSession(ctx context.Context, sid session.SessionID) error { + _, err := m.Store.Create(ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: sid, CreatedAtUnixMilli: m.now().UnixMilli()}) + return err +} + +// EnsureSession creates the stream when it does not exist yet. Create's +// idempotency needs field-identical requests, so existence is probed first. +func (m *Memory) EnsureSession(ctx context.Context, sid session.SessionID) error { + if _, err := m.Store.Header(ctx, sid); err == nil { + return nil + } else if !session.IsCode(err, session.ErrNotFound) { + return err + } + if err := m.CreateSession(ctx, sid); err != nil { + // A concurrent creator winning the race is still "exists". + if _, herr := m.Store.Header(ctx, sid); herr == nil { + return nil + } + return err + } + return nil +} + +// Open takes ownership of the Session and runs the takeover disposition +// (REF-DRV-5, RUN-CMT-7). It returns the number of recovery commands issued. +func (m *Memory) Open(ctx context.Context, sid session.SessionID) (int, error) { + if _, err := m.Writers.Writer(ctx, sid); err != nil { + return 0, err + } + return m.Runtime.RecoverInterrupted(ctx, sid) +} + +// Close releases every Session this assembly owns. +func (m *Memory) Close(ctx context.Context) error { return extension.CloseWriters(ctx, m.Writers) } + +// SubmitInput writes twilight/chatlog/input_submitted for one user text and +// returns the AgentInput a Start or Deliver hands to the Turn (REF-INP-2). +func (m *Memory) SubmitInput(ctx context.Context, sid session.SessionID, id run.InputID, text string) (run.AgentInput, error) { + content := InputContent(text) + w, err := m.Writers.Writer(ctx, sid) + if err != nil { + return run.AgentInput{}, err + } + res, err := w.Commit(ctx, func(extension.View) (*extension.SemanticGroup, error) { + return &extension.SemanticGroup{CommitID: session.CommitID("input-submitted/" + string(id)), Events: []extension.TypedEvent{{ + Type: chatlog.TypeInputSubmitted, RecordedAtUnixMilli: m.now().UnixMilli(), + Value: chatlog.InputSubmittedPayload{InputID: chatlog.InputID(id), Content: content, SubmittedAtUnixMilli: m.now().UnixMilli()}, + }}}, nil + }) + if err != nil { + return run.AgentInput{}, err + } + switch res.Outcome { + case extension.CommitApplied, extension.CommitAlreadyApplied: + return run.AgentInput{ID: id, Payload: content}, nil + default: + return run.AgentInput{}, fmt.Errorf("ref: submit input: %s: %s", res.Outcome, res.Detail) + } +} + +// SubmitText submits one user text under a fresh InputID (REF-INP-2). +func (m *Memory) SubmitText(ctx context.Context, sid session.SessionID, text string) (run.AgentInput, error) { + return m.SubmitInput(ctx, sid, NewInputID(), text) +} + +// Projection reads any registered projection through the Session's Writer — +// application modules read theirs here. +func (m *Memory) Projection(ctx context.Context, sid session.SessionID, id extension.ProjectionID, v extension.ProjectionVersion) (any, session.Head, error) { + return writersProjections{m.Writers}.Load(ctx, sid, id, v) +} + +// ChatlogSurface reads the chatlog surface projection. +func (m *Memory) ChatlogSurface(ctx context.Context, sid session.SessionID) (chatlog.Surface, error) { + state, _, err := m.Projection(ctx, sid, chatlog.SurfaceProjectionID, chatlog.SurfaceProjection.Version) + if err != nil { + return chatlog.Surface{}, err + } + return state.(chatlog.Surface), nil +} + +// TurnSurface reads the turn surface projection. +func (m *Memory) TurnSurface(ctx context.Context, sid session.SessionID) (turn.TurnSurface, error) { + state, _, err := m.Projection(ctx, sid, turn.SurfaceProjectionID, turn.SurfaceProjection.Version) + if err != nil { + return turn.TurnSurface{}, err + } + return state.(turn.TurnSurface), nil +} + +// SessionDriver routes user input to Deliver or Start and opens the next Turn +// after settlement (REF 4). It keeps no state of its own. +type SessionDriver struct { + Coordinator turn.Service + Memory *Memory + Profile turn.ProfileRef + Companion turn.CompanionVersion + // NewTurnID mints the next TurnID; nil selects the random default. + NewTurnID func() turn.TurnID +} + +// Send is REF-DRV-2: commit the input's route (Deliver into the active Turn, +// or Start a new one), then drive the Turn to its next quiescent point. +func (d *SessionDriver) Send(ctx context.Context, sid session.SessionID, inputs []run.AgentInput) (turn.TurnResponse, error) { + newTurnID := d.NewTurnID + if newTurnID == nil { + newTurnID = NewTurnID + } + surface, err := d.Memory.TurnSurface(ctx, sid) + if err != nil { + return turn.TurnResponse{}, err + } + var ref turn.TurnRef + if active, ok := surface.Active(); ok { + ref = turn.TurnRef{SessionID: sid, TurnID: active.TurnID} + if _, err := d.Coordinator.Deliver(ctx, turn.DeliverRequest{Ref: ref, Inputs: inputs}); err != nil { + return turn.TurnResponse{}, err + } + } else { + for _, v := range surface.Turns { + if v.Status == turn.TurnAttemptFailed { + return turn.TurnResponse{}, fmt.Errorf("%w: turn %s awaits Retry or Settle", turn.ErrConflict, v.TurnID) + } + } + ref = turn.TurnRef{SessionID: sid, TurnID: newTurnID()} + if _, err := d.Coordinator.Start(ctx, turn.StartRequest{Ref: ref, Inputs: inputs, + Profile: d.Profile, Companion: d.Companion}); err != nil { + return turn.TurnResponse{}, err + } + } + return d.Memory.Drive(ctx, ref) +} + +// OnTurnSettled is REF-DRV-3: start the next Turn from the backlog of +// submitted, undelivered inputs. +func (d *SessionDriver) OnTurnSettled(ctx context.Context, sid session.SessionID) (turn.TurnResponse, bool, error) { + surface, err := d.Memory.ChatlogSurface(ctx, sid) + if err != nil { + return turn.TurnResponse{}, false, err + } + pending := surface.SubmittedInputs() + if len(pending) == 0 { + return turn.TurnResponse{}, false, nil + } + inputs := make([]run.AgentInput, len(pending)) + for i, in := range pending { + inputs[i] = run.AgentInput{ID: run.InputID(in.ID), Payload: in.Content} + } + resp, err := d.Send(ctx, sid, inputs) + return resp, err == nil, err +} diff --git a/agent/ref/checkpoint.go b/agent/ref/checkpoint.go new file mode 100644 index 0000000..6ab0a7f --- /dev/null +++ b/agent/ref/checkpoint.go @@ -0,0 +1,287 @@ +package ref + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + "github.com/felinics/twilight/agent/turn" + "github.com/felinics/twilight/sdk" +) + +// CompactorSystemPrompt asks the profile's model for the checkpoint summary. +// Compaction is a host-level model call outside any Run: a crash while it +// generates writes nothing (REF-CKP-1). +const CompactorSystemPrompt = "You are the conversation compactor. Reply with a concise summary of the conversation transcript that preserves facts, decisions, names and open tasks. Reply with the summary text only." + +// RetainLast selects a pair-closed suffix of at most n entries: a retained +// tool result pulls in the assistant that issued its call, so the retained +// set stays valid provider input (REF-CKP-2). +func RetainLast(entries []chatlog.Entry, n int) []chatlog.EntryDigestPair { + if n <= 0 || len(entries) == 0 { + return nil + } + owner := map[chatlog.CallID]int{} // CallID -> index of the issuing assistant + for i, e := range entries { + if e.Kind != chatlog.EntryAssistant || e.Assistant == nil { + continue + } + for _, part := range e.Assistant.Parts { + if call, ok := part.(chatlog.ToolCallPart); ok { + owner[call.CallID] = i + } + } + } + start := len(entries) - n + if start < 0 { + start = 0 + } + for changed := true; changed; { + changed = false + for i := start; i < len(entries); i++ { + e := &entries[i] + if e.Kind != chatlog.EntryToolResult || e.ToolResult == nil { + continue + } + if at, ok := owner[e.ToolResult.CallID]; ok && at < start { + start = at + changed = true + } + } + } + out := make([]chatlog.EntryDigestPair, 0, len(entries)-start) + for i := start; i < len(entries); i++ { + out = append(out, entries[i].Pair()) + } + return out +} + +// Checkpoint commits a summary and its checkpoint in one group (CHT-EVT-3). +// The base is read inside the commit's critical section, so the digest pins +// exactly the context being replaced; a Turn must not be active. retain names +// entries of the current context (RetainLast builds a pair-closed suffix). +func (m *Memory) Checkpoint(ctx context.Context, sid session.SessionID, summaryText string, retain []chatlog.EntryDigestPair) (chatlog.CheckpointID, error) { + if strings.TrimSpace(summaryText) == "" { + return "", errors.New("ref: checkpoint requires a summary text") + } + w, err := m.Writers.Writer(ctx, sid) + if err != nil { + return "", err + } + checkpointID := chatlog.CheckpointID("ckpt-" + randomHex(8)) + summaryID := chatlog.SummaryID("sum-" + randomHex(8)) + res, err := w.Commit(ctx, func(v extension.View) (*extension.SemanticGroup, error) { + state, err := v.Projection(turn.SurfaceProjectionID, turn.SurfaceProjection.Version) + if err != nil { + return nil, err + } + tsurf := state.(turn.TurnSurface) + if active, ok := tsurf.Active(); ok { + return nil, fmt.Errorf("%w: checkpoint while turn %s is active", turn.ErrConflict, active.TurnID) + } + cstate, err := v.Projection(chatlog.ContextProjectionID, chatlog.ContextProjection.Version) + if err != nil { + return nil, err + } + entries := cstate.(chatlog.Context).Entries + if len(entries) == 0 { + return nil, errors.New("ref: checkpoint over an empty context") + } + if err := checkRetainClosure(entries, retain); err != nil { + return nil, err + } + pairs := make([]chatlog.EntryDigestPair, len(entries)) + for i := range entries { + pairs[i] = entries[i].Pair() + } + baseDigest, err := chatlog.DigestBaseContext(pairs) + if err != nil { + return nil, err + } + summary := chatlog.Summary{ID: summaryID, Parts: chatlog.Parts{chatlog.TextPart{Text: summaryText}}} + if summary.Digest, err = chatlog.DigestSummary(&summary); err != nil { + return nil, err + } + payload := chatlog.CheckpointCreatedPayload{ + CheckpointID: checkpointID, CoveredThrough: v.Head().Next - 1, + BaseContextDigest: baseDigest, SummaryID: summaryID, SummaryDigest: summary.Digest, + Retained: retain, + } + if payload.Digest, err = chatlog.DigestCheckpoint(&payload); err != nil { + return nil, err + } + now := m.now().UnixMilli() + return &extension.SemanticGroup{CommitID: session.CommitID("checkpoint/" + string(checkpointID)), Events: []extension.TypedEvent{ + {Type: chatlog.TypeSummary, RecordedAtUnixMilli: now, Value: chatlog.SummaryPayload{Summary: summary}}, + {Type: chatlog.TypeCheckpointCreated, RecordedAtUnixMilli: now, Value: payload}, + }}, nil + }) + if err != nil { + return "", err + } + switch res.Outcome { + case extension.CommitApplied, extension.CommitAlreadyApplied: + return checkpointID, nil + default: + return "", fmt.Errorf("ref: checkpoint: %s: %s", res.Outcome, res.Detail) + } +} + +// checkRetainClosure requires retained tool results and their issuing +// assistants to travel together, so the compacted context stays valid +// provider input (REF-CKP-2). Subset and order are the fold's job. +func checkRetainClosure(entries []chatlog.Entry, retain []chatlog.EntryDigestPair) error { + kept := make(map[chatlog.EntryDigestPair]bool, len(retain)) + for _, p := range retain { + kept[p] = true + } + owner := map[chatlog.CallID]*chatlog.Entry{} + for i := range entries { + e := &entries[i] + if e.Kind != chatlog.EntryAssistant || e.Assistant == nil { + continue + } + for _, part := range e.Assistant.Parts { + if call, ok := part.(chatlog.ToolCallPart); ok { + owner[call.CallID] = e + } + } + } + results := map[chatlog.CallID]*chatlog.Entry{} + for i := range entries { + e := &entries[i] + if e.Kind == chatlog.EntryToolResult && e.ToolResult != nil { + results[e.ToolResult.CallID] = e + } + } + for i := range entries { + e := &entries[i] + if !kept[e.Pair()] { + continue + } + switch e.Kind { + case chatlog.EntryToolResult: + if a := owner[e.ToolResult.CallID]; a != nil && !kept[a.Pair()] { + return fmt.Errorf("ref: retained tool_result %s without its assistant", e.ID) + } + case chatlog.EntryAssistant: + for _, part := range e.Assistant.Parts { + call, ok := part.(chatlog.ToolCallPart) + if !ok { + continue + } + if r := results[call.CallID]; r != nil && !kept[r.Pair()] { + return fmt.Errorf("ref: retained assistant %s without the result of call %s", e.ID, call.CallID) + } + } + } + } + return nil +} + +// Compact summarizes the context with the profile's model and commits a +// checkpoint retaining a pair-closed suffix; ok is false when the context is +// already within the retain window (REF-CKP-1). +func (s *Session) Compact(ctx context.Context) (chatlog.CheckpointID, bool, error) { + retainN := s.opts.CompactRetainEntries + if retainN <= 0 { + retainN = defaultCompactRetain + } + state, _, err := s.m.Projection(ctx, s.sid, chatlog.ContextProjectionID, chatlog.ContextProjection.Version) + if err != nil { + return "", false, err + } + entries := state.(chatlog.Context).Entries + retain := RetainLast(entries, retainN) + if len(entries) == 0 || len(retain) >= len(entries) { + return "", false, nil + } + summary, err := s.summarize(ctx, entries) + if err != nil { + return "", false, err + } + id, err := s.m.Checkpoint(ctx, s.sid, summary, retain) + if err != nil { + return "", false, err + } + return id, true, nil +} + +const defaultCompactRetain = 4 + +// summarize is the host-level model call: the profile's model reads a plain +// transcript and returns the summary text. +func (s *Session) summarize(ctx context.Context, entries []chatlog.Entry) (string, error) { + agent, err := s.m.Agents.Agent(s.driver.Profile) + if err != nil { + return "", err + } + profile := agent.Profile() + invoker, err := agent.ResolveModel(profile.Model) + if err != nil { + return "", err + } + res, err := invoker.Generate(ctx, sdk.Request{Model: string(profile.Model), Messages: []sdk.Message{ + sdk.SystemMessage(CompactorSystemPrompt), + sdk.UserMessage(renderTranscript(entries)), + }}) + if err != nil { + return "", err + } + if strings.TrimSpace(res.Text) == "" { + return "", errors.New("ref: compactor returned an empty summary") + } + return res.Text, nil +} + +// renderTranscript flattens entries into the compactor's input. +func renderTranscript(entries []chatlog.Entry) string { + var b strings.Builder + for i := range entries { + e := &entries[i] + switch e.Kind { + case chatlog.EntryInput: + text, err := v1InputText(e.Input.Content) + if err != nil { + text = e.Input.Content.String() + } + fmt.Fprintf(&b, "user: %s\n", text) + case chatlog.EntryAssistant: + for _, part := range e.Assistant.Parts { + switch v := part.(type) { + case chatlog.TextPart: + fmt.Fprintf(&b, "assistant: %s\n", v.Text) + case chatlog.ToolCallPart: + fmt.Fprintf(&b, "assistant: [calls %s %s]\n", v.Name, v.Input.String()) + } + } + case chatlog.EntryToolResult: + fmt.Fprintf(&b, "tool (%s): %s\n", e.ToolResult.Status, partsText(e.ToolResult.Parts)) + case chatlog.EntrySummary: + fmt.Fprintf(&b, "summary: %s\n", partsText(e.Summary.Parts)) + } + } + return b.String() +} + +// maybeCompact runs the automatic policy after a settlement; failures reach +// the host through CompactWarn and never change the settled results. +func (s *Session) maybeCompact(ctx context.Context) { + if s.opts.CompactAfterEntries <= 0 { + return + } + state, _, err := s.m.Projection(ctx, s.sid, chatlog.ContextProjectionID, chatlog.ContextProjection.Version) + if err == nil && len(state.(chatlog.Context).Entries) <= s.opts.CompactAfterEntries { + return + } + if err == nil { + _, _, err = s.Compact(ctx) + } + if err != nil && s.opts.CompactWarn != nil { + s.opts.CompactWarn(err) + } +} diff --git a/agent/ref/checkpoint_internal_test.go b/agent/ref/checkpoint_internal_test.go new file mode 100644 index 0000000..e9e68ba --- /dev/null +++ b/agent/ref/checkpoint_internal_test.go @@ -0,0 +1,77 @@ +package ref + +import ( + "testing" + + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/session/chatlog" +) + +func pairEntries() []chatlog.Entry { + in := chatlog.Input{ID: "in1", Digest: "sha256:in1"} + a1 := chatlog.Assistant{ID: "a1", TurnID: "t1", + Parts: chatlog.Parts{chatlog.ToolCallPart{CallID: "c1", Name: "lookup", Input: jsonstable.MustParse(`{}`)}}, Digest: "sha256:a1"} + r1 := chatlog.ToolResult{ID: "r1", TurnID: "t1", CallID: "c1", Status: chatlog.ToolSuccess, Digest: "sha256:r1"} + a2 := chatlog.Assistant{ID: "a2", TurnID: "t1", Parts: chatlog.Parts{chatlog.TextPart{Text: "done"}}, Digest: "sha256:a2"} + return []chatlog.Entry{ + {Kind: chatlog.EntryInput, ID: "in1", Digest: in.Digest, Seq: 1, Input: &in}, + {Kind: chatlog.EntryAssistant, ID: "a1", Digest: a1.Digest, Seq: 2, Assistant: &a1}, + {Kind: chatlog.EntryToolResult, ID: "r1", Digest: r1.Digest, Seq: 3, ToolResult: &r1}, + {Kind: chatlog.EntryAssistant, ID: "a2", Digest: a2.Digest, Seq: 4, Assistant: &a2}, + } +} + +// RetainLast expands a window that cuts a tool pair back to the issuing +// assistant, so the retained suffix stays valid provider input (REF-CKP-2). +func TestRetainLastPairClosure(t *testing.T) { + entries := pairEntries() + cases := []struct { + name string + n int + want []string + }{ + {"zero keeps nothing", 0, nil}, + {"suffix without pairs stays as asked", 1, []string{"a2"}}, + {"orphan result pulls in its assistant", 2, []string{"a1", "r1", "a2"}}, + {"window past the start keeps everything", 10, []string{"in1", "a1", "r1", "a2"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := RetainLast(entries, tc.n) + if len(got) != len(tc.want) { + t.Fatalf("retain = %+v, want ids %v", got, tc.want) + } + for i := range got { + if got[i].ID != tc.want[i] { + t.Fatalf("retain[%d] = %s, want %s", i, got[i].ID, tc.want[i]) + } + } + }) + } +} + +// checkRetainClosure rejects a retained set that splits a tool pair in either +// direction and accepts closed sets (REF-CKP-2). +func TestCheckRetainClosure(t *testing.T) { + entries := pairEntries() + pair := func(i int) chatlog.EntryDigestPair { return entries[i].Pair() } + cases := []struct { + name string + retain []chatlog.EntryDigestPair + wantErr bool + }{ + {"closed pair", []chatlog.EntryDigestPair{pair(1), pair(2)}, false}, + {"plain suffix", []chatlog.EntryDigestPair{pair(3)}, false}, + {"result without assistant", []chatlog.EntryDigestPair{pair(2)}, true}, + {"assistant without result", []chatlog.EntryDigestPair{pair(1)}, true}, + {"empty", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := checkRetainClosure(entries, tc.retain) + if (err != nil) != tc.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} diff --git a/agent/ref/checkpoint_test.go b/agent/ref/checkpoint_test.go new file mode 100644 index 0000000..face888 --- /dev/null +++ b/agent/ref/checkpoint_test.go @@ -0,0 +1,221 @@ +package ref_test + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/felinics/twilight/agent/ref" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/turn" + "github.com/felinics/twilight/sdk" +) + +// compactAwareModel answers turns with numbered replies and compactor +// requests (ref.CompactorSystemPrompt) with a fixed summary, recording every +// request. +type compactAwareModel struct { + mu sync.Mutex + seen []sdk.Request + replies int +} + +func (m *compactAwareModel) Generate(_ context.Context, req sdk.Request) (sdk.ModelResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.seen = append(m.seen, req) + if len(req.Messages) > 0 && req.Messages[0].Role == sdk.MessageRoleSystem && messageText(req.Messages[0]) == ref.CompactorSystemPrompt { + return sdk.ModelResult{Text: "summary-of-the-past", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}, nil + } + m.replies++ + return sdk.ModelResult{Text: fmt.Sprintf("reply-%d", m.replies), FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}, nil +} + +func (m *compactAwareModel) requests() []sdk.Request { + m.mu.Lock() + defer m.mu.Unlock() + return append([]sdk.Request(nil), m.seen...) +} + +func messageText(m sdk.Message) string { + var b strings.Builder + for _, part := range m.Content { + if t, ok := part.(sdk.TextPart); ok { + b.WriteString(t.Text) + } + } + return b.String() +} + +func messageTexts(req sdk.Request) []string { + out := make([]string, 0, len(req.Messages)) + for _, msg := range req.Messages { + out = append(out, string(msg.Role)+": "+messageText(msg)) + } + return out +} + +func openCompactSession(t *testing.T, store session.Store, model *compactAwareModel, opts ref.SessionOptions) (*ref.Memory, *ref.Session) { + t.Helper() + m, err := ref.New(ref.Options{Store: store, Ownership: session.OpenOptions{Takeover: true}}) + if err != nil { + t.Fatal(err) + } + agent, err := ref.NewAgent("m-1", model) + if err != nil { + t.Fatal(err) + } + profile, err := m.Agents.Register("b1", agent) + if err != nil { + t.Fatal(err) + } + opts.Profile = profile + s, err := m.OpenSession(context.Background(), "s-ckpt", opts) + if err != nil { + t.Fatal(err) + } + return m, s +} + +// An explicit Compact shrinks the next model request to the summary plus the +// retained suffix, and a restarted process assembles exactly the same context +// from the checkpointed log (CHT-EVT-3, REF-CKP-1). +func TestCompactShrinksContextAndReplaysAcrossRestart(t *testing.T) { + ctx := context.Background() + store := session.NewMemoryStore() + model := &compactAwareModel{} + m, s := openCompactSession(t, store, model, ref.SessionOptions{CompactRetainEntries: 1}) + + for _, text := range []string{"one", "two"} { + if _, err := s.Send(ctx, text); err != nil { + t.Fatal(err) + } + } + before := model.requests() + grown := before[len(before)-1] // user one, assistant reply-1, user two + if len(grown.Messages) != 3 { + t.Fatalf("pre-compact request = %v", messageTexts(grown)) + } + + id, ok, err := s.Compact(ctx) + if err != nil || !ok { + t.Fatalf("compact = %s %v %v", id, ok, err) + } + chat, err := m.ChatlogSurface(ctx, "s-ckpt") + if err != nil { + t.Fatal(err) + } + if v := chat.Checkpoints[id]; v.Status != chatlog.CheckpointActive { + t.Fatalf("checkpoint = %+v", v) + } + + if _, err := s.Send(ctx, "three"); err != nil { + t.Fatal(err) + } + reqs := model.requests() + compacted := reqs[len(reqs)-1] + want := []string{"assistant: summary-of-the-past", "assistant: reply-2", "user: three"} + if got := messageTexts(compacted); !equalStrings(got, want) { + t.Fatalf("post-compact request = %v, want %v", got, want) + } + + // "Restart": a second assembly over the same store must assemble the next + // request as exactly the settled continuation of the first process's view. + if err := s.Close(ctx); err != nil { + t.Fatal(err) + } + model2 := &compactAwareModel{} + model2.replies = 3 // keep reply numbering aligned for readability only + _, s2 := openCompactSession(t, store, model2, ref.SessionOptions{CompactRetainEntries: 1}) + if _, err := s2.Send(ctx, "four"); err != nil { + t.Fatal(err) + } + reqs2 := model2.requests() + next := messageTexts(reqs2[len(reqs2)-1]) + wantNext := append(messageTexts(compacted), "assistant: reply-3", "user: four") + if !equalStrings(next, wantNext) { + t.Fatalf("restarted request = %v, want %v", next, wantNext) + } +} + +// The automatic policy compacts after settlement once the context passes the +// threshold; failures reach CompactWarn only (REF-CKP-1). +func TestAutoCompactAfterSettlement(t *testing.T) { + ctx := context.Background() + var warned []error + model := &compactAwareModel{} + m, s := openCompactSession(t, store4(t), model, ref.SessionOptions{ + CompactAfterEntries: 3, CompactRetainEntries: 1, + CompactWarn: func(err error) { warned = append(warned, err) }, + }) + if _, err := s.Send(ctx, "one"); err != nil { // 2 entries, below threshold + t.Fatal(err) + } + if _, err := s.Send(ctx, "two"); err != nil { // 4 entries, compacts + t.Fatal(err) + } + if len(warned) != 0 { + t.Fatalf("warnings = %v", warned) + } + chat, err := m.ChatlogSurface(ctx, "s-ckpt") + if err != nil { + t.Fatal(err) + } + if len(chat.Checkpoints) != 1 { + t.Fatalf("checkpoints = %+v", chat.Checkpoints) + } + state, _, err := m.Projection(ctx, "s-ckpt", chatlog.ContextProjectionID, chatlog.ContextProjection.Version) + if err != nil { + t.Fatal(err) + } + if entries := state.(chatlog.Context).Entries; len(entries) != 2 || entries[0].Kind != chatlog.EntrySummary { + t.Fatalf("entries = %+v", entries) + } +} + +// Compact refuses while a Turn is active: compaction is a between-turns +// policy (REF-CKP-1). +func TestCompactRefusesWhileTurnActive(t *testing.T) { + ctx := context.Background() + tool := &gateTool{started: make(chan struct{}, 1), release: make(chan struct{})} + model := &scriptedRequests{answers: []sdk.ModelResult{toolCallAnswer()}} + m, profile, sid := setup(t, model, tool) + s, err := m.OpenSession(ctx, sid, ref.SessionOptions{Profile: profile, CompactRetainEntries: 1}) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, err := s.Send(ctx, "one") + done <- err + }() + <-tool.started + if _, _, err := s.Compact(ctx); !errors.Is(err, turn.ErrConflict) { + t.Fatalf("compact mid-turn = %v, want conflict", err) + } + close(tool.release) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func store4(t *testing.T) session.Store { + t.Helper() + return session.NewMemoryStore() +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/agent/ref/example_filestore_test.go b/agent/ref/example_filestore_test.go new file mode 100644 index 0000000..36b92c3 --- /dev/null +++ b/agent/ref/example_filestore_test.go @@ -0,0 +1,250 @@ +package ref_test + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/felinics/twilight/agent/ref" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/filestore" + "github.com/felinics/twilight/agent/turn" + "github.com/felinics/twilight/sdk" +) + +// Example_jsonlPrototype is the full prototype on the JSONL file store: one +// Session directory on disk carries the whole agent. +// +// Turn 1 shows steer and queue: while its tool call executes, a second Send +// routes to Deliver (the input joins the running Turn) and a third input is +// only submitted (it queues). After the Turn settles, OnTurnSettled starts +// Turn 2 from the queued input. +// +// Turn 2 shows resume: the process "crashes" while its tool call executes. +// A second Store instance over the same directory — a new process — opens +// with Takeover, disposes the abandoned call and Resume completes the Turn. +// The dead process's late settlement is fenced by owner.json. The log stays +// one JSONL file, readable with standard tools. +func Example_jsonlPrototype() { + ctx := context.Background() + const sid session.SessionID = "session-jsonl" + clock := &fakeClock{now: time.Unix(1_000_000, 0)} + root, err := os.MkdirTemp("", "twilight-jsonl-*") + if err != nil { + panic(err) + } + defer os.RemoveAll(root) + + tool := &stagedTool{} + frozen := run.NewMemoryFrozenValues() + + // ---- process 1 ---------------------------------------------------------- + store1, err := filestore.New(root) + if err != nil { + panic(err) + } + p1, err := ref.New(ref.Options{Store: store1, Frozen: frozen, Now: clock.Now}) + if err != nil { + panic(err) + } + if err := p1.CreateSession(ctx, sid); err != nil { + panic(err) + } + if _, err := p1.Open(ctx, sid); err != nil { + panic(err) + } + model1 := &scriptedRequests{answers: []sdk.ModelResult{protoToolCall("call-1"), protoText("done"), protoToolCall("call-2")}} + profile1, err := p1.Agents.Register("jsonl-agent", protoAgent(model1, tool)) + if err != nil { + panic(err) + } + turnSeq := 0 + driver := &ref.SessionDriver{Coordinator: p1.Coordinator, Memory: p1, Profile: profile1, Companion: turn.CompanionV1Version, + NewTurnID: func() turn.TurnID { turnSeq++; return turn.TurnID(fmt.Sprintf("turn-%d", turnSeq)) }} + + // Turn 1: Send starts the Turn; the model asks for the tool, which blocks. + stage1 := tool.stage() + in1, err := p1.SubmitInput(ctx, sid, "in-1", "what is the weather?") + if err != nil { + panic(err) + } + turn1Done := make(chan turn.TurnResponse, 1) + go func() { + resp, err := driver.Send(ctx, sid, []run.AgentInput{in1}) + if err != nil { + panic(err) + } + turn1Done <- resp + }() + <-stage1.started + + // Steer: a second Send while turn-1 runs routes to Deliver (REF-DRV-2). + in2, err := p1.SubmitInput(ctx, sid, "in-2", "and tomorrow?") + if err != nil { + panic(err) + } + steerDone := make(chan struct{}) + go func() { + defer close(steerDone) + // Deliver into the running Turn returns already_driving, not an error. + if _, err := driver.Send(ctx, sid, []run.AgentInput{in2}); err != nil { + panic(err) + } + }() + waitUntil(func() bool { + surface, err := p1.TurnSurface(ctx, sid) + return err == nil && len(surface.Turns["turn-1"].InputIDs) == 2 + }) + <-steerDone + chat, err := p1.ChatlogSurface(ctx, sid) + if err != nil { + panic(err) + } + fmt.Printf("steer: in-2 %s to turn-1 while its tool call executes\n", chat.Inputs["in-2"].Status) + + // Queue: in-3 is only submitted; nothing delivers it into the running Turn. + if _, err := p1.SubmitInput(ctx, sid, "in-3", "book a table"); err != nil { + panic(err) + } + chat, _ = p1.ChatlogSurface(ctx, sid) + fmt.Printf("queue: %d input pending while turn-1 runs\n", len(chat.SubmittedInputs())) + + close(stage1.release) + resp1 := <-turn1Done + fmt.Printf("turn-1: %s\n", resp1.Status) + + // Turn 2 opens from the backlog (REF-DRV-3); its tool call blocks and the + // process dies while the call is Executing. + stage2 := tool.stage() + turn2Err := make(chan error, 1) + go func() { + _, _, err := driver.OnTurnSettled(ctx, sid) + turn2Err <- err + }() + <-stage2.started + fmt.Println("turn-2: started from the queued input; tool call is Executing; process 1 crashes") + + // ---- process 2: a new Store instance over the same directory ------------- + store2, err := filestore.New(root) + if err != nil { + panic(err) + } + p2, err := ref.New(ref.Options{Store: store2, Frozen: frozen, Ownership: session.OpenOptions{Takeover: true}, Now: clock.Now}) + if err != nil { + panic(err) + } + if _, err := p2.Agents.Register("jsonl-agent", protoAgent(&scriptedRequests{}, tool)); err != nil { + panic(err) + } + recovered, err := p2.Open(ctx, sid) + if err != nil { + panic(err) + } + fmt.Printf("process 2: took over; %d executing target disposed\n", recovered) + + resp2, err := p2.Drive(ctx, turn.TurnRef{SessionID: sid, TurnID: "turn-2"}) + if err != nil { + panic(err) + } + fmt.Printf("turn-2: %s, disposition %s, attempt %d\n", resp2.Status, resp2.Disposition, resp2.Attempt) + + // The dead process's worker returns; owner.json fences its settlement. + close(stage2.release) + fmt.Printf("process 1: %v\n", errorsIsOwnershipLost(<-turn2Err)) + + // The whole Session is one JSONL file: one event per line, digest-chained. + page, err := store2.Read(ctx, session.ReadRequest{SessionID: sid}) + if err != nil { + panic(err) + } + raw, err := os.ReadFile(store2.LogPath(sid)) + if err != nil { + panic(err) + } + fmt.Printf("log.jsonl: %d lines, chain verified over %d rows\n", strings.Count(string(raw), "\n"), len(page.Events)) + fmt.Printf("first row: %s; last row: %s\n", page.Events[0].Type, page.Events[len(page.Events)-1].Type) + + // Output: + // steer: in-2 delivered to turn-1 while its tool call executes + // queue: 1 input pending while turn-1 runs + // turn-1: completed + // turn-2: started from the queued input; tool call is Executing; process 1 crashes + // process 2: took over; 1 executing target disposed + // turn-2: completed, disposition finished, attempt 1 + // process 1: ownership lost + // log.jsonl: 41 lines, chain verified over 41 rows + // first row: twilight/chatlog/input_submitted; last row: twilight/turn/completed +} + +func waitUntil(cond func() bool) { + deadline := time.Now().Add(10 * time.Second) + for !cond() { + if time.Now().After(deadline) { + panic("condition not reached") + } + time.Sleep(2 * time.Millisecond) + } +} + +func protoToolCall(id string) sdk.ModelResult { + return sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 1}, + ToolCalls: []sdk.ToolCall{{ToolCallID: id, ToolName: "lookup", Input: `{"q":"weather"}`}}} +} + +func protoText(text string) sdk.ModelResult { + return sdk.ModelResult{Text: text, FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}} +} + +func protoAgent(model loop.ModelInvoker, tool *stagedTool) ref.Agent { + agent, err := ref.NewAgent("m-1", model, ref.WithTool(tool)) + if err != nil { + panic(err) + } + return agent +} + +// stagedTool blocks each staged execution until its stage is released; +// executions beyond the staged ones run straight through. +type stagedTool struct { + mu sync.Mutex + stages []*toolStage +} + +type toolStage struct { + started chan struct{} + release chan struct{} +} + +func (t *stagedTool) stage() *toolStage { + st := &toolStage{started: make(chan struct{}), release: make(chan struct{})} + t.mu.Lock() + t.stages = append(t.stages, st) + t.mu.Unlock() + return st +} + +func (t *stagedTool) Ref() run.ToolRef { return "lookup" } +func (t *stagedTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{Name: "lookup", Parameters: []byte(`{"type":"object","properties":{"q":{"type":"string"}}}`)} +} +func (t *stagedTool) ResponsePolicy() run.ResponsePolicy { return run.DirectExecution } +func (t *stagedTool) ValidateArguments(run.CanonicalJSON) error { return nil } +func (t *stagedTool) Execute(_ context.Context, req loop.ToolExecutionRequest) loop.ToolExecutionOutcome { + t.mu.Lock() + var st *toolStage + if len(t.stages) > 0 { + st = t.stages[0] + t.stages = t.stages[1:] + } + t.mu.Unlock() + if st != nil { + close(st.started) + <-st.release + } + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} +} diff --git a/agent/ref/example_test.go b/agent/ref/example_test.go new file mode 100644 index 0000000..06153e1 --- /dev/null +++ b/agent/ref/example_test.go @@ -0,0 +1,222 @@ +package ref_test + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/felinics/twilight/agent/ref" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/turn" + "github.com/felinics/twilight/sdk" +) + +// Example_recoverableTurn drives one Turn through a process crash on the +// single Session stream. +// +// Process 1 owns the Session (Epoch 1), submits the user input and starts the +// Turn. The model asks for a tool; the tool never returns and the process dies +// while the call is Executing. Nothing is written on the way down. +// +// Process 2 reopens the same Session store with Takeover — the crashed owner +// never closed — and takes the Session over (Epoch 2). Its takeover +// disposition settles the +// abandoned call as Unknown in the same group as its chatlog tool_result, the +// Run stays Active, and Resume drives the Loop: the planner reads the +// conversation back from the chatlog projection and the Turn completes. The +// dead process's worker finally returns and its settlement is fenced by the +// kernel: nothing of Epoch 1 reaches the stream after the takeover. +func Example_recoverableTurn() { + ctx := context.Background() + const sid session.SessionID = "session-1" + clock := &fakeClock{now: time.Unix(1_000_000, 0)} + + // Shared "durable" state: the Session store and the frozen request bodies. + store := session.NewMemoryStore() + frozen := run.NewMemoryFrozenValues() + tool := &lookupTool{block: make(chan struct{})} + + // ---- process 1 ---------------------------------------------------------- + p1, err := ref.New(ref.Options{Store: store, Frozen: frozen, Now: clock.Now}) + if err != nil { + panic(err) + } + if err := p1.CreateSession(ctx, sid); err != nil { + panic(err) + } + if _, err := p1.Open(ctx, sid); err != nil { + panic(err) + } + profile1, err := p1.Agents.Register("weather-agent", newAgent(tool)) + if err != nil { + panic(err) + } + input, err := p1.SubmitInput(ctx, sid, "in-1", "what is the weather?") + if err != nil { + panic(err) + } + ref1 := turn.TurnRef{SessionID: sid, TurnID: "turn-1"} + startDone := make(chan error, 1) + go func() { + _, err := p1.Coordinator.Start(ctx, turn.StartRequest{Ref: ref1, Inputs: []run.AgentInput{input}, + Profile: profile1, Companion: turn.CompanionV1Version}) + if err == nil { + // The Coordinator only commits; the host drives (REF-DRV-1). + _, err = p1.Drive(ctx, ref1) + } + startDone <- err + }() + runID := waitForExecutingCall(ctx, p1, sid, ref1.TurnID) + fmt.Println("process 1: tool call is Executing; process crashes") + + // ---- process 2 ---------------------------------------------------------- + p2, err := ref.New(ref.Options{Store: store, Frozen: frozen, Ownership: session.OpenOptions{Takeover: true}, Now: clock.Now}) + if err != nil { + panic(err) + } + // The agent is re-registered from the same public configuration, so the + // profile ref the Session recorded still resolves. + if _, err := p2.Agents.Register("weather-agent", newAgent(tool)); err != nil { + panic(err) + } + recovered, err := p2.Open(ctx, sid) + if err != nil { + panic(err) + } + chat, err := p2.ChatlogSurface(ctx, sid) + if err != nil { + panic(err) + } + fmt.Printf("process 2: took over; %d executing target disposed; chatlog has %d tool_result(s) with status %s\n", recovered, len(chat.ToolResults), toolResultStatus(&chat)) + + resp, err := p2.Drive(ctx, ref1) + if err != nil { + panic(err) + } + fmt.Printf("process 2: turn %s, disposition %s, attempt %d\n", resp.Status, resp.Disposition, resp.Attempt) + + record, err := p2.Runtime.Record(ctx, sid, runID) + if err != nil { + panic(err) + } + chat, _ = p2.ChatlogSurface(ctx, sid) + fmt.Printf("record: %d run facts fold to the projection; chatlog entries: %d\n", len(record.Facts), len(chat.EntryOrder)) + + // Let the abandoned worker exit; its settlement is fenced because process + // 1's Epoch was superseded. + close(tool.block) + err = <-startDone + fmt.Printf("process 1: %v\n", errorsIsOwnershipLost(err)) + after, _ := p2.Runtime.Record(ctx, sid, runID) + fmt.Printf("stream unchanged by the fenced worker: %v\n", len(after.Facts) == len(record.Facts)) + + // Output: + // process 1: tool call is Executing; process crashes + // process 2: took over; 1 executing target disposed; chatlog has 1 tool_result(s) with status unknown + // process 2: turn completed, disposition finished, attempt 1 + // record: 12 run facts fold to the projection; chatlog entries: 4 + // process 1: ownership lost + // stream unchanged by the fenced worker: true +} + +func errorsIsOwnershipLost(err error) string { + if err == nil { + return "no error" + } + for e := err; e != nil; { + if e == run.ErrOwnershipLost { + return "ownership lost" + } + u, ok := e.(interface{ Unwrap() error }) + if !ok { + break + } + e = u.Unwrap() + } + return err.Error() +} + +func toolResultStatus(s *chatlog.Surface) string { + for _, r := range s.ToolResults { + return string(r.Status) + } + return "none" +} + +func waitForExecutingCall(ctx context.Context, m *ref.Memory, sid session.SessionID, turnID turn.TurnID) run.RunID { + deadline := time.Now().Add(10 * time.Second) + for { + surface, err := m.TurnSurface(ctx, sid) + if err == nil { + if v, ok := surface.Turns[turnID]; ok && v.ActiveRun != "" { + snap, err := m.Runtime.Load(ctx, sid, v.ActiveRun) + if err == nil && len(run.ExecutingCalls(snap.State)) == 1 { + return v.ActiveRun + } + } + } + if time.Now().After(deadline) { + panic("tool call never started") + } + time.Sleep(2 * time.Millisecond) + } +} + +func newAgent(tool *lookupTool) ref.Agent { + agent, err := ref.NewAgent("m-1", &scriptedModel{}, ref.WithTool(tool)) + if err != nil { + panic(err) + } + return agent +} + +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +// scriptedModel asks for the tool until a tool result is in the conversation, +// then answers. +type scriptedModel struct{} + +func (scriptedModel) Generate(_ context.Context, req sdk.Request) (sdk.ModelResult, error) { + if n := len(req.Messages); n > 0 && req.Messages[n-1].Role == sdk.MessageRoleTool { + return sdk.ModelResult{Text: "done", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}, nil + } + return sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{TotalTokens: 1}, + ToolCalls: []sdk.ToolCall{{ToolCallID: "c1", ToolName: "lookup", Input: `{"q":"weather"}`}}, + }, nil +} + +// lookupTool blocks on its first execution until block is closed. +type lookupTool struct { + block chan struct{} + ran atomic.Bool +} + +func (t *lookupTool) Ref() run.ToolRef { return "lookup" } +func (t *lookupTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{Name: "lookup", Parameters: []byte(`{"type":"object","properties":{"q":{"type":"string"}}}`)} +} +func (t *lookupTool) ResponsePolicy() run.ResponsePolicy { return run.DirectExecution } +func (t *lookupTool) ValidateArguments(run.CanonicalJSON) error { return nil } +func (t *lookupTool) Execute(_ context.Context, req loop.ToolExecutionRequest) loop.ToolExecutionOutcome { + if t.ran.CompareAndSwap(false, true) { + <-t.block + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} + } + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} +} diff --git a/agent/ref/frozen_restart_test.go b/agent/ref/frozen_restart_test.go new file mode 100644 index 0000000..6e90b7a --- /dev/null +++ b/agent/ref/frozen_restart_test.go @@ -0,0 +1,124 @@ +package ref_test + +import ( + "context" + "testing" + + "github.com/felinics/twilight/agent/ref" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/filestore" + "github.com/felinics/twilight/sdk" +) + +// gateModel blocks its first Generate until release, capturing the request: +// the process "crashes" while the ModelStep is Executing. +type gateModel struct { + started chan sdk.Request + release chan struct{} +} + +func (m *gateModel) Generate(_ context.Context, req sdk.Request) (sdk.ModelResult, error) { + m.started <- req + <-m.release + return sdk.ModelResult{Text: "late", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}, nil +} + +// The file-backed FrozenValueStore closes the model-interruption recovery +// path: process 1 dies while a ModelStep is Executing, and process 2 — whose +// FrozenValues instance is new, so the body can only come from disk — takes +// over (RecoverModelExecution returns the step to Prepared) and Resume +// replays the same frozen request to its model (RUN-WIR-4, RUN-CMT-7). +func TestFrozenRequestReplayedAcrossRestart(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + const sid session.SessionID = "s-frozen" + + // ---- process 1: the model call hangs; the process dies ------------------- + store1, err := filestore.New(root) + if err != nil { + t.Fatal(err) + } + frozen1, err := filestore.NewFrozenValues(root) + if err != nil { + t.Fatal(err) + } + p1, err := ref.New(ref.Options{Store: store1, Frozen: frozen1}) + if err != nil { + t.Fatal(err) + } + gate := &gateModel{started: make(chan sdk.Request, 1), release: make(chan struct{})} + agent1, err := ref.NewAgent("m-1", gate, ref.WithSystemPrompt("be brief")) + if err != nil { + t.Fatal(err) + } + profile, err := p1.Agents.Register("a1", agent1) + if err != nil { + t.Fatal(err) + } + s1, err := p1.OpenSession(ctx, sid, ref.SessionOptions{Profile: profile}) + if err != nil { + t.Fatal(err) + } + sendErr := make(chan error, 1) + go func() { + _, err := s1.Send(ctx, "what is the weather?") + sendErr <- err + }() + var sent sdk.Request + select { + case sent = <-gate.started: // the ModelStep is Executing; its body is on disk + case err := <-sendErr: + t.Fatalf("send returned before the model executed: %v", err) + } + + // ---- process 2: fresh instances over the same root ------------------------ + store2, err := filestore.New(root) + if err != nil { + t.Fatal(err) + } + frozen2, err := filestore.NewFrozenValues(root) + if err != nil { + t.Fatal(err) + } + p2, err := ref.New(ref.Options{Store: store2, Frozen: frozen2, Ownership: session.OpenOptions{Takeover: true}}) + if err != nil { + t.Fatal(err) + } + replay := &scriptedRequests{} + agent2, err := ref.NewAgent("m-1", replay, ref.WithSystemPrompt("be brief")) + if err != nil { + t.Fatal(err) + } + if _, err := p2.Agents.Register("a1", agent2); err != nil { + t.Fatal(err) + } + s2, err := p2.OpenSession(ctx, sid, ref.SessionOptions{Profile: profile}) + if err != nil { + t.Fatal(err) + } + if s2.Recovered != 1 { + t.Fatalf("recovered = %d, want 1 (the executing ModelStep)", s2.Recovered) + } + results, resumed, err := s2.Resume(ctx) + if err != nil || !resumed || len(results) == 0 { + t.Fatalf("resume = %+v %v %v", results, resumed, err) + } + if results[0].Status != "completed" { + t.Fatalf("turn = %s, want completed (a missing frozen body would leave it waiting)", results[0].Status) + } + + // The replayed request is the frozen one: same conversation, read from disk + // by a store instance that never saw the Put. + if len(replay.seen) != 1 { + t.Fatalf("replay model saw %d requests, want 1", len(replay.seen)) + } + if got, want := len(replay.seen[0].Messages), len(sent.Messages); got != want { + t.Fatalf("replayed request has %d messages, frozen one had %d", got, want) + } + + // The dead process's worker returns and is fenced. + close(gate.release) + if err := <-sendErr; err == nil { + t.Fatal("the superseded process's Send settled without an ownership error") + } +} diff --git a/agent/ref/planner.go b/agent/ref/planner.go new file mode 100644 index 0000000..707759e --- /dev/null +++ b/agent/ref/planner.go @@ -0,0 +1,186 @@ +package ref + +import ( + "context" + "errors" + "fmt" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + "github.com/felinics/twilight/sdk" +) + +type ( + extensionProjectionID = extension.ProjectionID + extensionProjectionVersion = extension.ProjectionVersion +) + +// ContextPlanner is the reference RequestPlanner (REF-PLN): it reads the +// chatlog context projection and assembles the next sdk.Request. Every +// assistant and tool_result of the Session is in the fold already, including +// those of earlier attempts of the same Turn (REF-PLN-6). +type ContextPlanner struct { + Projections ProjectionSource + Profile Profile + // InputText extracts the user text of one input payload; nil selects the + // v1 shape {"text": ...} (REF-INP-1). + InputText func(run.CanonicalJSON) (string, error) +} + +func (p *ContextPlanner) Plan(ctx context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { + if p.Projections == nil || p.Profile.Model == "" { + return loop.RequestPlan{}, errors.New("ref: planner requires projections and a model") + } + if hint.Session == "" { + return loop.RequestPlan{}, errors.New("ref: planner hint has no session") + } + state, head, err := p.Projections.Load(ctx, hint.Session, chatlog.ContextProjectionID, chatlog.ContextProjection.Version) + if err != nil { + return loop.RequestPlan{}, err + } + entries := state.(chatlog.Context).Entries + msgs, err := p.messages(entries) + if err != nil { + return loop.RequestPlan{}, err + } + specs, defs, err := p.Profile.ToolSpecs() + if err != nil { + return loop.RequestPlan{}, err + } + ids := make([]run.InputID, 0, len(hint.Inputs)) + for _, in := range hint.Inputs { + ids = append(ids, in.ID) + } + return loop.RequestPlan{ + Model: p.Profile.Model, + Request: sdk.Request{Model: string(p.Profile.Model), Messages: msgs, Tools: defs}, + InputIDs: ids, + PlanningToken: run.PlanningToken(fmt.Sprintf("%d:%s", head.Next, head.Digest)), + Tools: specs, + }, nil +} + +// messages is REF-PLN-2. +func (p *ContextPlanner) messages(entries []chatlog.Entry) ([]sdk.Message, error) { + var msgs []sdk.Message + if p.Profile.SystemPrompt != "" { + msgs = append(msgs, sdk.SystemMessage(p.Profile.SystemPrompt)) + } + inputText := p.InputText + if inputText == nil { + inputText = v1InputText + } + // ProviderCallID and tool name per CallID, from the assistant that issued + // the call, for pairing tool results (REF-PLN-2 step 2). + type callInfo struct{ provider, name string } + calls := map[chatlog.CallID]callInfo{} + // Inputs delivered mid-turn are committed while tool calls are still open + // (TRN-DLV-2); providers require tool results to follow their assistant + // message directly, so such inputs are held until the open calls resolve. + open := map[chatlog.CallID]struct{}{} + var deferred []sdk.Message + flushDeferred := func() { + if len(open) == 0 && len(deferred) > 0 { + msgs = append(msgs, deferred...) + deferred = nil + } + } + for i := range entries { + e := &entries[i] + switch e.Kind { + case chatlog.EntryInput: + text, err := inputText(e.Input.Content) + if err != nil { + return nil, err + } + if len(open) > 0 { + deferred = append(deferred, sdk.UserMessage(text)) + } else { + msgs = append(msgs, sdk.UserMessage(text)) + } + case chatlog.EntryAssistant: + flushDeferred() + var parts []sdk.MessagePart + for _, part := range e.Assistant.Parts { + switch v := part.(type) { + case chatlog.TextPart: + parts = append(parts, sdk.TextPart{Text: v.Text}) + case chatlog.ReasoningPart: + parts = append(parts, sdk.ReasoningPart{Text: v.Text}) + case chatlog.ToolCallPart: + calls[v.CallID] = callInfo{provider: v.ProviderCallID, name: v.Name} + open[v.CallID] = struct{}{} + input, err := v.Input.Any() + if err != nil { + return nil, err + } + parts = append(parts, sdk.ToolCallPart{ToolCallID: v.ProviderCallID, ToolName: v.Name, Input: input}) + case chatlog.ReferencePart: + // v1 reference planner: no materializer; the reference is named. + parts = append(parts, sdk.TextPart{Text: "[attachment " + v.Name + "]"}) + } + } + if len(parts) > 0 { + msgs = append(msgs, sdk.Message{Role: sdk.MessageRoleAssistant, Content: parts}) + } + case chatlog.EntryToolResult: + r := e.ToolResult + info := calls[r.CallID] + part := sdk.ToolResultPart{ToolCallID: info.provider, ToolName: info.name} + text := partsText(r.Parts) + switch r.Status { + case chatlog.ToolSuccess: + part.Result = text + case chatlog.ToolError: + part.Result, part.IsError = text, true + case chatlog.ToolUnknown: + part.Result, part.IsError = "tool outcome unknown: "+text, true + } + msgs = append(msgs, sdk.ToolMessage(part)) + delete(open, r.CallID) + flushDeferred() + case chatlog.EntrySummary: + flushDeferred() + msgs = append(msgs, sdk.AssistantMessage(partsText(e.Summary.Parts))) + } + } + // Calls left open (a stopped attempt) never resolve: release the inputs. + msgs = append(msgs, deferred...) + return msgs, nil +} + +func partsText(parts chatlog.Parts) string { + var out string + for _, part := range parts { + switch v := part.(type) { + case chatlog.TextPart: + out += v.Text + case chatlog.ReferencePart: + out += "[attachment " + v.Name + "]" + } + } + return out +} + +func v1InputText(content run.CanonicalJSON) (string, error) { + var body struct { + Text string `json:"text"` + } + if err := content.Decode(&body); err != nil { + return "", fmt.Errorf("ref: input payload: %w", err) + } + return body.Text, nil +} + +// InputContent is the v1 user body shape (REF-INP-1). +func InputContent(text string) run.CanonicalJSON { + return run.MustParseCanonicalJSON(fmt.Sprintf(`{"text":%s}`, mustJSONString(text))) +} + +func mustJSONString(s string) string { + raw, _ := es.MarshalCanonical(s) + return string(raw) +} diff --git a/agent/ref/session.go b/agent/ref/session.go new file mode 100644 index 0000000..a52fdb0 --- /dev/null +++ b/agent/ref/session.go @@ -0,0 +1,284 @@ +package ref + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "strings" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/turn" +) + +// NewTurnID mints a collision-free TurnID. +func NewTurnID() turn.TurnID { return turn.TurnID("turn-" + randomHex(8)) } + +// NewInputID mints a collision-free InputID; chatlog requires session-global +// uniqueness across restarts. +func NewInputID() run.InputID { return run.InputID("in-" + randomHex(8)) } + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("ref: rand: " + err.Error()) + } + return hex.EncodeToString(b) +} + +// SessionOptions tunes OpenSession. +type SessionOptions struct { + // Profile is the agent configuration new Turns run under (required). + Profile turn.ProfileRef + // Companion defaults to turn.CompanionV1Version. + Companion turn.CompanionVersion + // ResumeActive resumes a still-active Turn synchronously inside + // OpenSession. Interactive hosts leave it false and call Resume themselves. + ResumeActive bool + // CompactAfterEntries triggers automatic compaction when the context + // grows past this many entries after a settlement; zero disables it. + CompactAfterEntries int + // CompactRetainEntries is the pair-closed suffix a compaction keeps + // verbatim; zero selects the default. + CompactRetainEntries int + // CompactWarn receives automatic-compaction failures; they never change + // the settled results. Nil discards them. + CompactWarn func(error) +} + +// Result is the conversation-level outcome of one settled (or steered) Turn. +type Result struct { + TurnID turn.TurnID + Status turn.TurnStatus + Disposition turn.ResumeDisposition + // Reply is the settled Turn's last assistant text; empty while the Turn + // still runs (already_driving) or when the attempt produced no text. + Reply string +} + +// SessionStatus reports what a host may need to act on after opening. +type SessionStatus struct { + Active turn.TurnID + Failed []turn.TurnID +} + +// Session is the host-facing object over one open Session: it submits text, +// routes it (Deliver into the running Turn, or Start), drains the backlog of +// queued inputs after settlement, and reads replies from the chatlog. +// Concurrent Send calls are safe: writes serialize in the Session Writer, and +// a Send that lands in a running Turn returns already_driving. +type Session struct { + // Recovered is the takeover disposition count from opening (RUN-CMT-7). + Recovered int + + m *Memory + sid session.SessionID + driver *SessionDriver + opts SessionOptions +} + +// OpenSession ensures the stream exists, takes ownership per the assembly's +// Ownership options, runs the takeover disposition and returns the host +// object. +func (m *Memory) OpenSession(ctx context.Context, sid session.SessionID, opts SessionOptions) (*Session, error) { + if opts.Profile.ID == "" || opts.Profile.Digest == "" { + return nil, errors.New("ref: open session requires a profile ref") + } + companion := opts.Companion + if companion == "" { + companion = turn.CompanionV1Version + } + if err := m.EnsureSession(ctx, sid); err != nil { + return nil, err + } + recovered, err := m.Open(ctx, sid) + if err != nil { + return nil, err + } + s := &Session{Recovered: recovered, m: m, sid: sid, opts: opts, + driver: &SessionDriver{Coordinator: m.Coordinator, Memory: m, Profile: opts.Profile, Companion: companion}} + if opts.ResumeActive { + if _, _, err := s.Resume(ctx); err != nil { + return nil, err + } + } + return s, nil +} + +// Status reports the active Turn and the Turns awaiting Retry or Settle. +func (s *Session) Status(ctx context.Context) (SessionStatus, error) { + surface, err := s.m.TurnSurface(ctx, s.sid) + if err != nil { + return SessionStatus{}, err + } + var out SessionStatus + if v, ok := surface.Active(); ok { + out.Active = v.TurnID + } + for _, id := range surface.Order { + if surface.Turns[id].Status == turn.TurnAttemptFailed { + out.Failed = append(out.Failed, id) + } + } + return out, nil +} + +// Send submits text and blocks until it is settled or absorbed: the first +// Result is the Turn the input landed in, further Results are backlog Turns +// this call drained after settlement. Concurrent Sends race on routing +// (Deliver or Start); a lost race re-routes, and an input another driver +// already took returns as already_driving. +func (s *Session) Send(ctx context.Context, text string) ([]Result, error) { + in, err := s.m.SubmitText(ctx, s.sid, text) + if err != nil { + return nil, err + } + var lastErr error + for attempt := 0; attempt < 4; attempt++ { + resp, err := s.driver.Send(ctx, s.sid, []run.AgentInput{in}) + if err == nil { + return s.settled(ctx, resp) + } + if !errors.Is(err, turn.ErrConflict) { + return nil, err + } + lastErr = err + if r, taken := s.absorbed(ctx, in); taken { + return []Result{r}, nil + } + } + return nil, lastErr +} + +// absorbed reports whether another driver already delivered the input; the +// Turn that took it settles and reports there. +func (s *Session) absorbed(ctx context.Context, in run.AgentInput) (Result, bool) { + chat, err := s.m.ChatlogSurface(ctx, s.sid) + if err != nil { + return Result{}, false + } + v, ok := chat.Inputs[chatlog.InputID(in.ID)] + if !ok || v.Status == chatlog.InputSubmitted { + return Result{}, false + } + r := Result{TurnID: turn.TurnID(v.Input.TurnID), Disposition: ResumeAlreadyDriving} + if surface, serr := s.m.TurnSurface(ctx, s.sid); serr == nil { + r.Status = surface.Turns[r.TurnID].Status + } + return r, true +} + +// Resume drives a still-active Turn (after a restart) to settlement; ok is +// false when no Turn is active. +func (s *Session) Resume(ctx context.Context) ([]Result, bool, error) { + status, err := s.Status(ctx) + if err != nil { + return nil, false, err + } + if status.Active == "" { + return nil, false, nil + } + resp, err := s.m.Drive(ctx, turn.TurnRef{SessionID: s.sid, TurnID: status.Active}) + if err != nil { + return nil, false, err + } + out, err := s.settled(ctx, resp) + return out, true, err +} + +// Retry retries the first Turn awaiting Retry; ok is false when none is. +func (s *Session) Retry(ctx context.Context) ([]Result, bool, error) { + status, err := s.Status(ctx) + if err != nil { + return nil, false, err + } + if len(status.Failed) == 0 { + return nil, false, nil + } + ref := turn.TurnRef{SessionID: s.sid, TurnID: status.Failed[0]} + if _, err := s.m.Coordinator.Retry(ctx, turn.RetryRequest{Ref: ref, Reason: "host retry"}); err != nil { + return nil, false, err + } + resp, err := s.m.Drive(ctx, ref) + if err != nil { + return nil, false, err + } + out, err := s.settled(ctx, resp) + return out, true, err +} + +// Close releases this Session's Writer; other Sessions of the assembly stay +// open. +func (s *Session) Close(ctx context.Context) error { + w, err := s.m.Writers.Writer(ctx, s.sid) + if err != nil { + return err + } + return w.Close(ctx) +} + +// settled turns a TurnResponse into Results and drains the backlog: while a +// settlement leaves submitted, undelivered inputs, the next Turn starts from +// them (REF-DRV-3). +func (s *Session) settled(ctx context.Context, resp turn.TurnResponse) ([]Result, error) { + out := []Result{s.result(ctx, resp)} + if resp.Disposition == ResumeAlreadyDriving { + // The running driver settles the Turn and drains in its own call. + return out, nil + } + for range [64]struct{}{} { + next, ok, err := s.driver.OnTurnSettled(ctx, s.sid) + if err != nil { + if errors.Is(err, turn.ErrConflict) { + // A concurrent Send or drain took the backlog; it reports there. + return out, nil + } + return out, err + } + if !ok { + // The backlog is drained and no Turn is active: the automatic + // compaction policy runs here (REF-CKP-1). + s.maybeCompact(ctx) + return out, nil + } + out = append(out, s.result(ctx, next)) + if next.Disposition == ResumeAlreadyDriving { + return out, nil + } + } + return out, errors.New("ref: drain did not converge") +} + +func (s *Session) result(ctx context.Context, resp turn.TurnResponse) Result { + r := Result{TurnID: resp.Ref.TurnID, Status: resp.Status, Disposition: resp.Disposition} + if resp.Disposition == turn.ResumeFinished { + if chat, err := s.m.ChatlogSurface(ctx, s.sid); err == nil { + r.Reply = lastAssistantText(&chat, chatlog.TurnID(resp.Ref.TurnID)) + } + } + return r +} + +// lastAssistantText is the text of the Turn's last assistant entry. +func lastAssistantText(chat *chatlog.Surface, turnID chatlog.TurnID) string { + for i := len(chat.EntryOrder) - 1; i >= 0; i-- { + e := chat.EntryOrder[i] + if e.Kind != chatlog.EntryAssistant { + continue + } + a, ok := chat.Assistants[chatlog.AssistantID(e.ID)] + if !ok || a.TurnID != turnID { + continue + } + var b strings.Builder + for _, part := range a.Parts { + if t, isText := part.(chatlog.TextPart); isText { + b.WriteString(t.Text) + } + } + return b.String() + } + return "" +} diff --git a/agent/ref/turn_test.go b/agent/ref/turn_test.go new file mode 100644 index 0000000..f514615 --- /dev/null +++ b/agent/ref/turn_test.go @@ -0,0 +1,235 @@ +package ref_test + +import ( + "context" + "testing" + "time" + + "github.com/felinics/twilight/agent/ref" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/turn" + "github.com/felinics/twilight/sdk" +) + +// gateTool blocks each execution until released, so tests can act mid-step. +type gateTool struct { + started chan struct{} + release chan struct{} +} + +func (t *gateTool) Ref() run.ToolRef { return "lookup" } +func (t *gateTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{Name: "lookup", Parameters: []byte(`{"type":"object"}`)} +} +func (t *gateTool) ResponsePolicy() run.ResponsePolicy { return run.DirectExecution } +func (t *gateTool) ValidateArguments(run.CanonicalJSON) error { return nil } +func (t *gateTool) Execute(_ context.Context, req loop.ToolExecutionRequest) loop.ToolExecutionOutcome { + t.started <- struct{}{} + <-t.release + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} +} + +// scriptedRequests records every request the model saw and answers from a +// script: tool call first, then text. +type scriptedRequests struct { + seen []sdk.Request + answers []sdk.ModelResult +} + +func (m *scriptedRequests) Generate(_ context.Context, req sdk.Request) (sdk.ModelResult, error) { + m.seen = append(m.seen, req) + if len(m.answers) == 0 { + return sdk.ModelResult{Text: "done", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}, nil + } + next := m.answers[0] + m.answers = m.answers[1:] + return next, nil +} + +func toolCallAnswer() sdk.ModelResult { + return sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 1}, + ToolCalls: []sdk.ToolCall{{ToolCallID: "c1", ToolName: "lookup", Input: `{"q":"weather"}`}}} +} + +func setup(t *testing.T, model loop.ModelInvoker, tool *gateTool) (*ref.Memory, turn.ProfileRef, session.SessionID) { + t.Helper() + m, err := ref.New(ref.Options{}) + if err != nil { + t.Fatal(err) + } + const sid session.SessionID = "s-1" + if err := m.CreateSession(context.Background(), sid); err != nil { + t.Fatal(err) + } + agent, err := ref.NewAgent("m-1", model, ref.WithTool(tool), ref.WithSystemPrompt("be brief")) + if err != nil { + t.Fatal(err) + } + profile, err := m.Agents.Register("b1", agent) + if err != nil { + t.Fatal(err) + } + return m, profile, sid +} + +// An input delivered while a tool call is Executing queues on the Run, is +// delivered to the same Turn in the same commit, and reaches the model in the +// next request together with the tool result (TRN-DLV, RUN-LOP-8). +func TestDeliverMidTurnReachesNextModelRequest(t *testing.T) { + ctx := context.Background() + tool := &gateTool{started: make(chan struct{}, 1), release: make(chan struct{})} + model := &scriptedRequests{answers: []sdk.ModelResult{toolCallAnswer()}} + m, binding, sid := setup(t, model, tool) + + first, err := m.SubmitInput(ctx, sid, "in-1", "what is the weather?") + if err != nil { + t.Fatal(err) + } + ref1 := turn.TurnRef{SessionID: sid, TurnID: "t1"} + done := make(chan turn.TurnResponse, 1) + go func() { + resp, err := m.Coordinator.Start(ctx, turn.StartRequest{Ref: ref1, Inputs: []run.AgentInput{first}, Profile: binding, Companion: turn.CompanionV1Version}) + if err == nil { + // The Coordinator only commits; the host drives (REF-DRV-1). + resp, err = m.Drive(ctx, ref1) + } + if err != nil { + t.Error(err) + } + done <- resp + }() + <-tool.started + + second, err := m.SubmitInput(ctx, sid, "in-2", "and tomorrow?") + if err != nil { + t.Fatal(err) + } + driver := &ref.SessionDriver{Coordinator: m.Coordinator, Memory: m, Profile: binding, Companion: turn.CompanionV1Version, NewTurnID: func() turn.TurnID { return "t2" }} + // Deliver commits AcceptInput + input_delivered without waiting for the + // tool; the Run is already driven here, so the response reports + // already_driving (or finished when the running driver settles first). + deliverDone := make(chan turn.TurnResponse, 1) + go func() { + resp, err := driver.Send(ctx, sid, []run.AgentInput{second}) + if err != nil { + t.Error(err) + } + deliverDone <- resp + }() + // The Deliver commit lands while the tool runs; the Loop sees PendingInputs + // at its next Load. Release the tool and let both drivers finish. + waitFor(t, func() bool { + surface, err := m.TurnSurface(ctx, sid) + return err == nil && len(surface.Turns["t1"].InputIDs) == 2 + }) + close(tool.release) + if resp := <-deliverDone; resp.Disposition != ref.ResumeAlreadyDriving && resp.Disposition != turn.ResumeFinished { + t.Fatalf("deliver disposition = %s", resp.Disposition) + } + resp := <-done + if resp.Status != turn.TurnCompleted { + t.Fatalf("turn status = %s, want completed", resp.Status) + } + if len(model.seen) != 2 { + t.Fatalf("model requests = %d, want 2", len(model.seen)) + } + last := model.seen[1].Messages + var users []string + for _, msg := range last { + if msg.Role == sdk.MessageRoleUser { + users = append(users, msg.Content[0].(sdk.TextPart).Text) + } + } + if len(users) != 2 || users[1] != "and tomorrow?" { + t.Fatalf("second request user messages = %v", users) + } + if last[len(last)-2].Role != sdk.MessageRoleTool { + t.Fatalf("tool result did not precede the delivered input: %+v", roles(last)) + } + chat, err := m.ChatlogSurface(ctx, sid) + if err != nil { + t.Fatal(err) + } + if got := chat.Inputs["in-2"]; got.Status != chatlog.InputDelivered || got.Input.TurnID != "t1" { + t.Fatalf("in-2 = %+v, want delivered to t1", got) + } +} + +// Stop settles the Turn as stopped in the same commit as CancelRun; a later +// Send opens a new Turn whose planner sees the stopped Turn's content. +func TestStopSettlesTurnAndNextSendStartsNewTurn(t *testing.T) { + ctx := context.Background() + tool := &gateTool{started: make(chan struct{}, 1), release: make(chan struct{})} + model := &scriptedRequests{answers: []sdk.ModelResult{toolCallAnswer()}} + m, binding, sid := setup(t, model, tool) + first, _ := m.SubmitInput(ctx, sid, "in-1", "hello") + ref1 := turn.TurnRef{SessionID: sid, TurnID: "t1"} + done := make(chan struct{}) + go func() { + defer close(done) + if _, err := m.Coordinator.Start(ctx, turn.StartRequest{Ref: ref1, Inputs: []run.AgentInput{first}, Profile: binding, Companion: turn.CompanionV1Version}); err == nil { + _, _ = m.Drive(ctx, ref1) + } + }() + <-tool.started + + resp, err := m.Coordinator.Stop(ctx, turn.StopRequest{Ref: ref1, Reason: "user"}) + if err != nil { + t.Fatal(err) + } + if resp.Status != turn.TurnStopped || resp.Disposition != turn.ResumeFinished || resp.End == nil { + t.Fatalf("stop response = %+v", resp) + } + if _, stopped := (*resp.End).(run.RunStoppedEnd); !stopped { + t.Fatalf("end = %#v, want RunStoppedEnd", *resp.End) + } + close(tool.release) + <-done + + // The abandoned worker's settlement was rejected; the Run is terminal. + record, err := m.Runtime.Record(ctx, sid, resp.RunID) + if err != nil { + t.Fatal(err) + } + if record.Snapshot.State.Status != run.RunStopped || len(record.Snapshot.State.Result.UncertainCalls) != 1 { + t.Fatalf("stopped run = %+v", record.Snapshot.State.Result) + } + + second, _ := m.SubmitInput(ctx, sid, "in-2", "again") + driver := &ref.SessionDriver{Coordinator: m.Coordinator, Memory: m, Profile: binding, Companion: turn.CompanionV1Version, NewTurnID: func() turn.TurnID { return "t2" }} + resp2, err := driver.Send(ctx, sid, []run.AgentInput{second}) + if err != nil { + t.Fatal(err) + } + if resp2.Ref.TurnID != "t2" || resp2.Status != turn.TurnCompleted { + t.Fatalf("second send = %+v", resp2) + } + // The new Turn's request carried the stopped Turn's assistant tool call and + // its unknown tool_result (REF-PLN-6), then the new input. + last := model.seen[len(model.seen)-1].Messages + if got := roles(last); len(got) != 5 || got[0] != "system" || got[1] != "user" || got[2] != "assistant" || got[3] != "tool" || got[4] != "user" { + t.Fatalf("roles = %v", got) + } +} + +func roles(msgs []sdk.Message) []string { + out := make([]string, len(msgs)) + for i, m := range msgs { + out[i] = string(m.Role) + } + return out +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("condition not reached") + } + time.Sleep(time.Millisecond) + } +} diff --git a/agent/run/arguments.go b/agent/run/arguments.go new file mode 100644 index 0000000..cb62486 --- /dev/null +++ b/agent/run/arguments.go @@ -0,0 +1,53 @@ +package run + +import "encoding/json" + +// canonicalToolArguments renders a model-provided tool input as canonical JSON +// for binding digests. Failure means the arguments are not valid JSON; the +// caller binds them raw-as-JSON-string and lets validation fail as +// invalid_arguments. +func canonicalToolArguments(input any) (CanonicalJSON, error) { + switch x := input.(type) { + case nil: + return ParseCanonicalJSON([]byte("null")) + case CanonicalJSON: + return x, nil + case json.RawMessage: + return ParseCanonicalJSON(x) + case string: + // Providers deliver unparsed argument text as a string. + if x == "" { + return ParseCanonicalJSON([]byte("null")) + } + return ParseCanonicalJSON([]byte(x)) + default: + return CanonicalJSONFromValue(x) + } +} + +// rawToolArguments preserves unparsable argument bytes as a JSON string so the +// known invalid_arguments failure keeps the original text for the model. +func rawToolArguments(input any) CanonicalJSON { + var raw []byte + switch x := input.(type) { + case nil: + raw = []byte("null") + case CanonicalJSON: + return x + case json.RawMessage: + raw, _ = json.Marshal(string(x)) + case string: + raw, _ = json.Marshal(x) + default: + var err error + raw, err = json.Marshal(x) + if err != nil { + raw = []byte("null") + } + } + v, err := ParseCanonicalJSON(raw) + if err != nil { + return MustParseCanonicalJSON("null") + } + return v +} diff --git a/agent/run/canonical.go b/agent/run/canonical.go new file mode 100644 index 0000000..cb00fb0 --- /dev/null +++ b/agent/run/canonical.go @@ -0,0 +1,32 @@ +// Package run implements the Twilight Run Machine, persisted protocol, +// Runtime authority boundary, verified fold, and canonical identities. +// +// See docs/design/agent-run.md for the governing specification. The in-process +// execution interpreter and its model/tool ports are in agent/run/loop. +package run + +import ( + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/jsonstable" +) + +// CanonicalJSON is an immutable, agent-owned canonical JSON value. It can only +// be built by parsing external bytes through ParseCanonicalJSON or by +// marshaling a Go JSON-shaped value through CanonicalJSONFromValue. +type CanonicalJSON = jsonstable.Value + +func ParseCanonicalJSON(raw []byte) (CanonicalJSON, error) { + return jsonstable.Parse(raw) +} + +func CanonicalJSONFromValue(v any) (CanonicalJSON, error) { + return jsonstable.FromValue(v) +} + +func MustParseCanonicalJSON(raw string) CanonicalJSON { + return jsonstable.MustParse(raw) +} + +func canonicalJSON(raw []byte) ([]byte, error) { return es.Canonicalize(raw) } + +func marshalCanonical(v any) ([]byte, error) { return es.MarshalCanonical(v) } diff --git a/agent/run/canonical_test.go b/agent/run/canonical_test.go new file mode 100644 index 0000000..030340c --- /dev/null +++ b/agent/run/canonical_test.go @@ -0,0 +1,204 @@ +package run + +import ( + "encoding/json" + "testing" +) + +func TestFreezeToolCallInputPreservesMalformedJSONText(t *testing.T) { + for _, input := range []any{`{"x":`, json.RawMessage(`{"x":`)} { + got, err := FreezeToolCallInput(input) + if err != nil { + t.Fatalf("FreezeToolCallInput(%T): %v", input, err) + } + if got.String() != `"{\"x\":"` { + t.Fatalf("FreezeToolCallInput(%T) = %s", input, got.String()) + } + } + + for _, input := range []any{string([]byte{0xff}), json.RawMessage{0xff}} { + if _, err := FreezeToolCallInput(input); err == nil { + t.Fatalf("FreezeToolCallInput(%T) accepted invalid UTF-8", input) + } + } +} + +// RFC 8785 appendix test vectors plus structural cases. +func TestCanonicalJSON(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"key sort ascii", `{"b":1,"a":2}`, `{"a":2,"b":1}`}, + {"nested objects", `{"z":{"b":1,"a":[true,null]},"a":"x"}`, `{"a":"x","z":{"a":[true,null],"b":1}}`}, + {"whitespace stripped", "{\n \"a\" : 1 ,\t\"b\": [ 1 , 2 ]\n}", `{"a":1,"b":[1,2]}`}, + // RFC 8785 §3.2.3: sort by UTF-16 code units — surrogate pairs (𝄞) + // sort after BMP chars like € and 替. + {"utf16 order", `{"𝄞":1,"€":2,"replace":3}`, `{"replace":3,"€":2,"𝄞":1}`}, + {"number integer", `{"a":1.0}`, `{"a":1}`}, + {"number negative zero", `{"a":-0}`, `{"a":0}`}, + {"number e-notation collapse", `{"a":1e+3}`, `{"a":1000}`}, + {"number small", `{"a":0.000001}`, `{"a":0.000001}`}, + {"number tiny goes exponential", `{"a":0.0000001}`, `{"a":1e-7}`}, + {"number large stays plain to 1e21", `{"a":100000000000000000000}`, `{"a":100000000000000000000}`}, + {"number 1e21 exponential", `{"a":1e21}`, `{"a":1e+21}`}, + {"number JSONB expanded 1e21", `{"a":1000000000000000000000}`, `{"a":1e+21}`}, + {"number shortest roundtrip", `{"a":0.1}`, `{"a":0.1}`}, + {"string escapes minimal", `{"a":"A\nB\u0041"}`, "{\"a\":\"A\\nBA\"}"}, + {"string control chars", `{"a":"\u0001"}`, "{\"a\":\"\\u0001\"}"}, + {"string unicode passthrough", `{"a":"\u00e9"}`, `{"a":"é"}`}, + {"string surrogate pair", `{"a":"\ud834\udd1e"}`, `{"a":"𝄞"}`}, + {"array order preserved", `[3,1,2]`, `[3,1,2]`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := canonicalJSON([]byte(c.in)) + if err != nil { + t.Fatalf("canonicalJSON(%q): %v", c.in, err) + } + if string(got) != c.want { + t.Fatalf("canonicalJSON(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestCanonicalJSONRejects(t *testing.T) { + for _, in := range []string{ + ``, `{"a":1}garbage`, `{bad}`, + `"\ud800"`, `"\udbff"`, `"\udc00"`, `"\ud800x"`, `"\ud800\u0041"`, + `{"a":1,"a":2}`, `{"dry_run":true,"dry_run":false}`, + `{"x":1}]`, `{"a":1}}}`, `[1,2]]`, + "{\"a\":\"\xff\"}", + } { + if _, err := canonicalJSON([]byte(in)); err == nil { + t.Fatalf("canonicalJSON(%q): expected error", in) + } + } +} + +func TestCanonicalDeterminism(t *testing.T) { + // Map iteration order must not leak into canonical bytes. + v := map[string]any{"z": 1, "a": map[string]any{"y": []any{1, "s"}, "b": true}, "m": nil} + first, err := marshalCanonical(v) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 50; i++ { + got, err := marshalCanonical(v) + if err != nil { + t.Fatal(err) + } + if string(got) != string(first) { + t.Fatalf("non-deterministic canonical output: %q vs %q", got, first) + } + } +} + +func TestDigestPreimageCoversSchemaVersion(t *testing.T) { + cmd := StartToolCall{StepID: "s1", CallID: "c1", Claim: "claim-1"} + body1, err := encodeEnvelopeBody(SchemaVersion1, "start_tool_call", cmd) + if err != nil { + t.Fatal(err) + } + body2, err := encodeEnvelopeBody(2, "start_tool_call", cmd) + if err != nil { + t.Fatal(err) + } + if string(body1) == string(body2) { + t.Fatal("schema version did not affect digest preimage") + } +} + +func TestDeriveStability(t *testing.T) { + // Fixed inputs must produce fixed outputs across processes; freeze a few. + id1 := DeriveModelRequestCommandID("run-1", 7) + id2 := DeriveModelRequestCommandID("run-1", 7) + if id1 != id2 { + t.Fatal("derive is not deterministic") + } + if id1 == DeriveModelRequestCommandID("run-1", 8) { + t.Fatal("revision does not separate command IDs") + } + if id1 == DeriveModelRequestCommandID("run-1", 70) { + t.Fatal("index does not separate command IDs") + } + if id1 == DeriveModelRequestCommandID("run-2", 7) { + t.Fatal("run does not separate command IDs") + } + // Namespaces must not collide even with aligned parts. + a := namespacedHash("twilight/model-step", "x", "y") + b := namespacedHash("twilight/tool-step", "x", "y") + if a == b { + t.Fatal("namespace does not separate hashes") + } + // Length prefixing prevents concatenation collisions. + c := namespacedHash("n", "ab", "c") + d := namespacedHash("n", "a", "bc") + if c == d { + t.Fatal("part boundaries do not separate hashes") + } +} + +func TestDeriveResponseIDPerKind(t *testing.T) { + a := DeriveResponseID("r", "s", "c", ResponseApproval) + b := DeriveResponseID("r", "s", "c", ResponseExternal) + if a == b { + t.Fatal("response kind does not separate response IDs") + } +} + +func TestDigestBindingCanonicalizesArguments(t *testing.T) { + d1, err := digestToolCallBinding("c1", "sha256:x", DirectExecution, cj(`{"b":1,"a":2}`)) + if err != nil { + t.Fatal(err) + } + d2, err := digestToolCallBinding("c1", "sha256:x", DirectExecution, cj(`{ "a" : 2, "b" : 1 }`)) + if err != nil { + t.Fatal(err) + } + if d1 != d2 { + t.Fatal("argument formatting leaked into binding digest") + } + d3, _ := digestToolCallBinding("c1", "sha256:x", ApprovalRequired, cj(`{"a":2,"b":1}`)) + if d1 == d3 { + t.Fatal("policy does not affect binding digest") + } + id1, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"channel_id":"9007199254740993"}`)) + if err != nil { + t.Fatal(err) + } + id2, err := digestToolCallBinding("c", "", DirectExecution, cj(`{"channel_id":"9007199254740992"}`)) + if err != nil { + t.Fatal(err) + } + if id1 == id2 { + t.Fatal("distinct string identifiers collided in a binding digest") + } +} + +// Golden vectors for the current pre-release SchemaVersion 1. They guard the +// current canonical encoding; update them deliberately when the pre-release +// protocol changes. Once v1 is published, these become permanent fixtures. +func TestSchemaVersion1Golden(t *testing.T) { + cmd := CancelRun{Reason: ReasonCancelled} + body, err := encodeEnvelopeBody(SchemaVersion1, "cancel_run", cmd) + if err != nil { + t.Fatal(err) + } + wantBody := `v1:10:cancel_run:{"reason":"cancelled"}` + if string(body) != wantBody { + t.Fatalf("golden body changed:\n got %q\nwant %q", body, wantBody) + } + + fact := InputAccepted{Input: AgentInput{ID: "in-1", Payload: cj(`{"text":"hi"}`)}} + fbody, err := ProtocolV1().EncodeFact("input_accepted", fact) + if err != nil { + t.Fatal(err) + } + wantFact := `v1:14:input_accepted:{"input":{"id":"in-1","payload":{"text":"hi"}}}` + if string(fbody) != wantFact { + t.Fatalf("golden fact body changed:\n got %q\nwant %q", fbody, wantFact) + } +} diff --git a/agent/run/clone.go b/agent/run/clone.go new file mode 100644 index 0000000..cf66dcd --- /dev/null +++ b/agent/run/clone.go @@ -0,0 +1,299 @@ +package run + +import "encoding/json" + +// Deep-copy helpers: Runtime return values must be read-only snapshots +// (RUN-CMT-6) — a caller mutating a returned slice or map must never reach +// authoritative storage or committed event bytes. +// +// The agent Runtime is an authority boundary. All persisted request/result +// shapes are agent-owned JSON-stable values, so cloning is mechanical: copy +// structs and copy slice/map containers. CanonicalJSON values are immutable. + +func cloneRaw(v CanonicalJSON) CanonicalJSON { return v } + +func clonePtr[T any](p *T) *T { + if p == nil { + return nil + } + v := *p + return &v +} + +func cloneProviderMetadata(meta ProviderMetadata) ProviderMetadata { + if meta == nil { + return nil + } + out := make(ProviderMetadata, len(meta)) + for k, v := range meta { + out[k] = cloneRaw(v) + } + return out +} + +func cloneCacheControl(c *CacheControl) *CacheControl { + if c == nil { + return nil + } + cc := *c + return &cc +} + +func cloneAgentInput(in AgentInput) AgentInput { + in.Payload = cloneRaw(in.Payload) + return in +} + +func cloneAgentInputs(ins []AgentInput) []AgentInput { + if ins == nil { + return nil + } + out := make([]AgentInput, len(ins)) + for i, in := range ins { + out[i] = cloneAgentInput(in) + } + return out +} + +func cloneResponseRequest(r *ResponseRequest) *ResponseRequest { + if r == nil { + return nil + } + c := *r + c.Payload = cloneRaw(c.Payload) + return &c +} + +func cloneToolCallFailure(f *ToolCallFailure) *ToolCallFailure { + if f == nil { + return nil + } + c := *f + return &c +} + +func cloneToolCallState(c *ToolCallState) ToolCallState { + out := *c + out.Arguments = cloneRaw(out.Arguments) + out.Result = clonePtr(out.Result) + out.Failure = cloneToolCallFailure(out.Failure) + out.Waiting = cloneResponseRequest(out.Waiting) + return out +} + +func cloneToolCallBinding(b *ToolCallBinding) ToolCallBinding { + out := *b + out.Arguments = cloneRaw(out.Arguments) + out.Response = cloneResponseRequest(out.Response) + return out +} + +func cloneToolCallBindings(bs []ToolCallBinding) []ToolCallBinding { + if bs == nil { + return nil + } + out := make([]ToolCallBinding, len(bs)) + for i := range bs { + out[i] = cloneToolCallBinding(&bs[i]) + } + return out +} + +func cloneToolDefinition(d ToolDefinition) ToolDefinition { + d.Parameters = cloneRaw(d.Parameters) + d.CacheControl = cloneCacheControl(d.CacheControl) + return d +} + +func cloneToolSpecs(specs []ToolSpec) []ToolSpec { + if specs == nil { + return nil + } + return append([]ToolSpec(nil), specs...) +} + +func cloneResponseFormat(f *ResponseFormat) *ResponseFormat { + if f == nil { + return nil + } + c := *f + c.JSONSchema = cloneRaw(c.JSONSchema) + return &c +} + +func cloneMessagePart(p *MessagePart) MessagePart { + out := *p + out.Input = cloneRaw(out.Input) + out.Result = cloneRaw(out.Result) + out.CacheControl = cloneCacheControl(out.CacheControl) + out.ProviderMetadata = cloneProviderMetadata(out.ProviderMetadata) + return out +} + +func cloneMessages(messages []Message) []Message { + if messages == nil { + return nil + } + out := make([]Message, len(messages)) + for i, m := range messages { + if m.Content != nil { + parts := make([]MessagePart, len(m.Content)) + for j := range m.Content { + parts[j] = cloneMessagePart(&m.Content[j]) + } + m.Content = parts + } + m.Usage = clonePtr(m.Usage) + out[i] = m + } + return out +} + +func cloneRequest(r *ModelRequest) ModelRequest { + out := *r + out.Messages = cloneMessages(out.Messages) + out.Tools = cloneToolDefinitions(out.Tools) + out.ResponseFormat = cloneResponseFormat(out.ResponseFormat) + out.Temperature = clonePtr(out.Temperature) + out.TopP = clonePtr(out.TopP) + out.MaxTokens = clonePtr(out.MaxTokens) + out.FrequencyPenalty = clonePtr(out.FrequencyPenalty) + out.PresencePenalty = clonePtr(out.PresencePenalty) + out.Seed = clonePtr(out.Seed) + out.ReasoningEffort = clonePtr(out.ReasoningEffort) + out.ReasoningSummary = clonePtr(out.ReasoningSummary) + out.PromptCacheKey = clonePtr(out.PromptCacheKey) + out.StopSequences = append([]string(nil), out.StopSequences...) + if out.ProviderOptions != nil { + opts := make(map[string]CanonicalJSON, len(out.ProviderOptions)) + for k, v := range out.ProviderOptions { + opts[k] = cloneRaw(v) + } + out.ProviderOptions = opts + } + return out +} + +func cloneToolDefinitions(defs []ToolDefinition) []ToolDefinition { + if defs == nil { + return nil + } + out := make([]ToolDefinition, len(defs)) + for i, d := range defs { + out[i] = cloneToolDefinition(d) + } + return out +} + +func cloneRunResult(r *RunResult) *RunResult { + if r == nil { + return nil + } + c := *r + if c.Failure != nil { + f := *c.Failure + c.Failure = &f + } + c.UncertainCalls = append([]CallID(nil), c.UncertainCalls...) + return &c +} + +func cloneStep(s Step) Step { + switch step := s.(type) { + case ModelStep: + step.Tools = cloneToolSpecs(step.Tools) + return step + case ToolStep: + calls := make([]ToolCallState, len(step.Calls)) + for i := range step.Calls { + calls[i] = cloneToolCallState(&step.Calls[i]) + } + step.Calls = calls + return step + default: + return s + } +} + +func cloneCurrent(c Current) Current { + switch cur := c.(type) { + case Open: + return Open{} + case ModelStep: + return cloneStep(cur).(ModelStep) + case ToolStep: + return cloneStep(cur).(ToolStep) + default: + return c + } +} + +func cloneToolStepPtr(s *ToolStep) *ToolStep { + if s == nil { + return nil + } + step := cloneStep(*s).(ToolStep) + return &step +} + +func cloneMachineState(s *MachineState) MachineState { + out := *s + if out.Current != nil { + out.Current = cloneCurrent(out.Current) + } + out.LastToolStep = cloneToolStepPtr(out.LastToolStep) + out.PendingInputs = cloneAgentInputs(out.PendingInputs) + out.Result = cloneRunResult(out.Result) + return out +} + +func snapshotJSONStable[T any](v T) (T, error) { + var out T + raw, err := marshalCanonical(v) + if err != nil { + return out, err + } + if err := json.Unmarshal(raw, &out); err != nil { + return out, err + } + return out, nil +} + +// snapshotFact detaches the caller-owned containers a fact may still share +// with its command (tool specs, bindings, input payloads). Digest-only facts +// carry no such containers and are copied by value. +func snapshotFact(f Fact) (Fact, error) { + switch fact := f.(type) { + case ModelStepPrepared: + return snapshotJSONStable(fact) + case ToolStepOpened: + return snapshotJSONStable(fact) + case InputAccepted: + return snapshotJSONStable(fact) + default: + return cloneFact(f), nil + } +} + +func cloneFact(f Fact) Fact { + switch fact := f.(type) { + case ModelStepPrepared: + fact.InputIDs = append([]InputID(nil), fact.InputIDs...) + fact.Tools = cloneToolSpecs(fact.Tools) + return fact + case ToolStepOpened: + fact.Calls = cloneToolCallBindings(fact.Calls) + return fact + case InputAccepted: + fact.Input = cloneAgentInput(fact.Input) + return fact + case RunEnded: + if stopped, ok := fact.End.(RunStoppedEnd); ok { + stopped.UncertainCalls = append([]CallID(nil), stopped.UncertainCalls...) + fact.End = stopped + } + return fact + default: + return f + } +} diff --git a/agent/run/codec.go b/agent/run/codec.go new file mode 100644 index 0000000..b3e3ec9 --- /dev/null +++ b/agent/run/codec.go @@ -0,0 +1,226 @@ +package run + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/felinics/twilight/agent/session" +) + +type commandEnvelopeWire struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + SessionID session.SessionID `json:"sessionId,omitempty"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Command json.RawMessage `json:"command"` +} + +type commandEnvelopeMarshal struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + SessionID session.SessionID `json:"sessionId,omitempty"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Command AgentCommand `json:"command"` +} + +// DecodeCommandEnvelope decodes the command wire shape and restores the +// sealed command variant from Type; malformed or unsupported wire data is +// rejected before it can enter Runtime. +func DecodeCommandEnvelope(raw []byte) (CommandEnvelope, error) { + var env CommandEnvelope + if err := decodeStrictJSON(raw, &env); err != nil { + return CommandEnvelope{}, err + } + return env, nil +} + +//nolint:gocritic // hugeParam: value receiver keeps json.Marshaler active for non-pointer CommandEnvelope values. +func (e CommandEnvelope) MarshalJSON() ([]byte, error) { + if e.Command == nil { + return nil, errors.New("agent: codec: command envelope has nil command") + } + typ := commandType(e.Command) + if typ == "" { + return nil, fmt.Errorf("agent: codec: unknown command variant %T", e.Command) + } + if e.Type != "" && e.Type != typ { + return nil, fmt.Errorf("agent: codec: command type %q does not match variant %q", e.Type, typ) + } + return json.Marshal(commandEnvelopeMarshal{ + SchemaVersion: e.SchemaVersion, + Type: typ, + SessionID: e.SessionID, + RunID: e.RunID, + ID: e.ID, + Command: e.Command, + }) +} + +func (e *CommandEnvelope) UnmarshalJSON(raw []byte) error { + var wire commandEnvelopeWire + if err := decodeStrictJSON(raw, &wire); err != nil { + return err + } + proto, err := ProtocolFor(wire.SchemaVersion) + if err != nil { + return err + } + cmd, err := proto.DecodeCommand(wire.Type, wire.Command) + if err != nil { + return err + } + if err := requireCanonicalEquivalent(raw, commandEnvelopeMarshal{ + SchemaVersion: wire.SchemaVersion, + Type: wire.Type, + SessionID: wire.SessionID, + RunID: wire.RunID, + ID: wire.ID, + Command: cmd, + }); err != nil { + return err + } + *e = CommandEnvelope{ + SchemaVersion: wire.SchemaVersion, + Type: wire.Type, + SessionID: wire.SessionID, + RunID: wire.RunID, + ID: wire.ID, + Command: cmd, + } + return nil +} + +func isSupportedSchemaVersion(v uint16) bool { + return v == SchemaVersion1 +} + +func requireCanonicalEquivalent(raw []byte, canonicalShape any) error { + rawCanonical, err := canonicalJSON(raw) + if err != nil { + return err + } + shapeCanonical, err := marshalCanonical(canonicalShape) + if err != nil { + return err + } + if !bytes.Equal(rawCanonical, shapeCanonical) { + return errors.New("agent: codec: JSON shape does not match canonical protocol fields") + } + return nil +} + +func decodeStrictJSON(raw []byte, dst any) error { + canonical, err := canonicalJSON(raw) + if err != nil { + return err + } + dec := json.NewDecoder(bytes.NewReader(canonical)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return errors.New("agent: codec: trailing data after JSON value") + } + return err + } + return nil +} + +func decodeCommandAs[T AgentCommand](raw []byte) (AgentCommand, error) { + var c T + err := decodeStrictJSON(raw, &c) + return c, err +} + +func decodeFactAs[T Fact](raw []byte) (Fact, error) { + var f T + err := decodeStrictJSON(raw, &f) + return f, err +} + +func decodeCommandVariantV1(typ string, raw []byte) (AgentCommand, error) { + if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil, fmt.Errorf("agent: codec: command %q has empty body", typ) + } + switch typ { + case "prepare_model_request": + return decodeCommandAs[PrepareModelRequest](raw) + case "withdraw_prepared_step": + return decodeCommandAs[WithdrawPreparedStep](raw) + case "start_model_execution": + return decodeCommandAs[StartModelExecution](raw) + case "recover_model_execution": + return decodeCommandAs[RecoverModelExecution](raw) + case "submit_model_result": + return decodeCommandAs[SubmitModelResult](raw) + case "submit_model_failure": + return decodeCommandAs[SubmitModelFailure](raw) + case "reject_model_result": + return decodeCommandAs[RejectModelResult](raw) + case "start_tool_call": + return decodeCommandAs[StartToolCall](raw) + case "submit_tool_result": + return decodeCommandAs[SubmitToolResult](raw) + case "submit_tool_failure": + return decodeCommandAs[SubmitToolFailure](raw) + case "approve_tool_call": + return decodeCommandAs[ApproveToolCall](raw) + case "reject_tool_call": + return decodeCommandAs[RejectToolCall](raw) + case "submit_tool_response": + return decodeCommandAs[SubmitToolResponse](raw) + case "cancel_run": + return decodeCommandAs[CancelRun](raw) + case "accept_input": + return decodeCommandAs[AcceptInput](raw) + default: + return nil, fmt.Errorf("agent: codec: unknown command type %q", typ) + } +} + +func decodeFactVariantV1(typ string, raw []byte) (Fact, error) { + if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil, fmt.Errorf("agent: codec: fact %q has empty body", typ) + } + switch typ { + case "run_created": + return decodeFactAs[RunCreated](raw) + case "model_step_prepared": + return decodeFactAs[ModelStepPrepared](raw) + case "model_step_withdrawn": + return decodeFactAs[ModelStepWithdrawn](raw) + case "model_step_started": + return decodeFactAs[ModelStepStarted](raw) + case "model_step_recovered": + return decodeFactAs[ModelStepRecovered](raw) + case "model_step_rejected": + return decodeFactAs[ModelStepRejected](raw) + case "model_step_completed": + return decodeFactAs[ModelStepCompleted](raw) + case "tool_step_opened": + return decodeFactAs[ToolStepOpened](raw) + case "tool_call_started": + return decodeFactAs[ToolCallStarted](raw) + case "tool_call_approved": + return decodeFactAs[ToolCallApproved](raw) + case "tool_call_completed": + return decodeFactAs[ToolCallCompleted](raw) + case "tool_call_answered": + return decodeFactAs[ToolCallAnswered](raw) + case "tool_call_failed": + return decodeFactAs[ToolCallFailed](raw) + case "input_accepted": + return decodeFactAs[InputAccepted](raw) + case "run_ended": + return decodeFactAs[RunEnded](raw) + default: + return nil, fmt.Errorf("agent: codec: unknown fact type %q", typ) + } +} diff --git a/agent/run/codec_test.go b/agent/run/codec_test.go new file mode 100644 index 0000000..d0b4fc5 --- /dev/null +++ b/agent/run/codec_test.go @@ -0,0 +1,187 @@ +package run + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +func TestCommandEnvelopeJSONRoundTripRestoresVariants(t *testing.T) { + commands := []AgentCommand{ + PrepareModelRequest{StepID: "s", Model: "m", Request: ModelRequest{Model: "m"}, RequestDigest: "sha256:req", ToolsDigest: "sha256:tools"}, + WithdrawPreparedStep{StepID: "s"}, + StartModelExecution{StepID: "s", Claim: "claim-s"}, + RecoverModelExecution{StepID: "s", Claim: "claim-s"}, + SubmitModelResult{StepID: "s", Result: ModelResult{Text: "ok"}}, + SubmitModelFailure{StepID: "s", Failure: StepFailure{Class: FailureProvider, Message: "down"}}, + RejectModelResult{StepID: "s", Usage: Usage{TotalTokens: 1}, Failure: StepFailure{Class: FailureMalformedModel}}, + StartToolCall{StepID: "ts", CallID: "c", Claim: "claim-c"}, + SubmitToolResult{StepID: "ts", CallID: "c", Result: ToolExecutionResult{Output: cj(`{"ok":true}`)}}, + SubmitToolFailure{StepID: "ts", CallID: "c", Failure: ToolFailure{Class: FailureExecution}, Outcome: ToolOutcomeKnown}, + ApproveToolCall{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, + RejectToolCall{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Reason: "no"}, + SubmitToolResponse{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp", Payload: cj(`{"answer":1}`)}, + CancelRun{}, + AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, + } + for _, cmd := range commands { + env, err := ProtocolV1().BuildEnvelope("s-1", "run-1", CommandID("cmd-"+commandType(cmd)), cmd) + if err != nil { + t.Fatalf("ProtocolV1().BuildEnvelope(%T): %v", cmd, err) + } + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal(%T): %v", cmd, err) + } + decoded, err := DecodeCommandEnvelope(raw) + if err != nil { + t.Fatalf("DecodeCommandEnvelope(%T): %v\n%s", cmd, err, raw) + } + if reflect.TypeOf(decoded.Command) != reflect.TypeOf(cmd) { + t.Fatalf("decoded command type = %T, want %T", decoded.Command, cmd) + } + if decoded.Type != env.Type || decoded.ID != env.ID { + t.Fatalf("decoded envelope = %+v, want %+v", decoded, env) + } + } +} + +// Every fact variant round-trips through the v1 fact codec: canonical bytes +// decode back to the same variant and re-encode to the same bytes. +func TestFactCodecRoundTripRestoresVariants(t *testing.T) { + facts := []Fact{ + RunCreated{SchemaVersion: SchemaVersion1, RunID: "run-1", Owner: "turn-1", Attempt: 1, CausationID: "cause"}, + ModelStepPrepared{StepID: "s", Model: "m", RequestDigest: "sha256:req", ToolsDigest: "sha256:tools", BindingDigest: "sha256:binding"}, + ModelStepWithdrawn{StepID: "s"}, + ModelStepStarted{StepID: "s"}, + ModelStepRecovered{StepID: "s"}, + ModelStepRejected{StepID: "s", Usage: Usage{TotalTokens: 1}, Failure: StepFailure{Class: FailureMalformedModel}}, + ModelStepCompleted{StepID: "s", Usage: Usage{TotalTokens: 1}, FinishReason: FinishReasonStop, ResultDigest: "sha256:result"}, + ToolStepOpened{StepID: "ts", Source: "s", BindingSetDigest: "sha256:set", Calls: []ToolCallBinding{{CallID: "c", ToolRef: "t", BindingDigest: "sha256:binding", Arguments: cj(`{}`), Policy: DirectExecution}}}, + ToolCallStarted{StepID: "ts", CallID: "c"}, + ToolCallApproved{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, + ToolCallCompleted{StepID: "ts", CallID: "c", OutputDigest: "sha256:output"}, + ToolCallAnswered{StepID: "ts", CallID: "c", ResponseID: "r", ResponseDigest: "sha256:resp"}, + ToolCallFailed{StepID: "ts", CallID: "c", Failure: ToolFailure{Class: FailureExecution}, Outcome: ToolOutcomeKnown}, + InputAccepted{Input: AgentInput{ID: "in", Payload: cj(`{"q":"hi"}`)}}, + RunEnded{End: RunCompletedEnd{}}, + } + for _, fact := range facts { + typ := factType(fact) + raw, err := marshalCanonical(fact) + if err != nil { + t.Fatalf("marshal(%T): %v", fact, err) + } + decoded, err := ProtocolV1().DecodeFact(typ, raw) + if err != nil { + t.Fatalf("DecodeFact(%T): %v\n%s", fact, err, raw) + } + if reflect.TypeOf(decoded) != reflect.TypeOf(fact) { + t.Fatalf("decoded fact type = %T, want %T", decoded, fact) + } + again, err := marshalCanonical(decoded) + if err != nil || string(again) != string(raw) { + t.Fatalf("re-encode of %T differs:\n%s\n%s", fact, raw, again) + } + if _, err := ProtocolV1().DecodeFact("unknown", raw); err == nil { + t.Fatalf("unknown fact type decoded for %T", fact) + } + } +} + +func TestWireCodecRejectsAmbiguousJSONBeforeVariantDecode(t *testing.T) { + cmd := AcceptInput{Input: AgentInput{ID: "in", Payload: cj(`1`)}} + env, err := ProtocolV1().BuildEnvelope("s-1", "run-1", DeriveInputCommandID("run-1", "in"), cmd) + if err != nil { + t.Fatal(err) + } + raw := []byte(fmt.Sprintf(`{"schemaVersion":1,"type":"accept_input","runId":"run-1","id":%q,"command":{"input":{"id":"in","payload":1},"input":{"id":"in","payload":1}}}`, env.ID)) + if _, err := DecodeCommandEnvelope(raw); err == nil { + t.Fatal("duplicate key command decoded") + } + + canonical, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + wrongCase := strings.Replace(string(canonical), `"command":`, `"Command":`, 1) + if _, err := DecodeCommandEnvelope([]byte(wrongCase)); err == nil { + t.Fatal("case-insensitive command field decoded") + } +} + +func TestWireCodecRejectsUnknownType(t *testing.T) { + env, err := ProtocolV1().BuildEnvelope("s-1", "run-1", "cmd-1", CancelRun{}) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + badType := strings.Replace(string(raw), `"type":"cancel_run"`, `"type":"unknown"`, 1) + if _, err := DecodeCommandEnvelope([]byte(badType)); err == nil { + t.Fatal("unknown command type decoded") + } +} + +func TestRunEndedTaggedUnionRejectsInvalidValues(t *testing.T) { + for name, fact := range map[string]RunEnded{ + "nil end": {}, + "stopped without reason": {End: RunStoppedEnd{}}, + "failed without class": {End: RunFailedEnd{Reason: ReasonProviderFailure}}, + "unknown end variant": {End: fakeRunEnd{}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := ProtocolV1().DigestFact("run_ended", fact); err == nil { + t.Fatal("invalid tagged terminal value was accepted") + } + }) + } +} + +type fakeRunEnd struct{} + +func (fakeRunEnd) runEnd() {} + +// RunEnded wire is a tagged union: exactly one variant key. +func TestRunEndedWireIsTaggedUnion(t *testing.T) { + cases := map[string]RunEnded{ + "completed": {End: RunCompletedEnd{}}, + "stopped": {End: RunStoppedEnd{Reason: ReasonCancelled, UncertainCalls: []CallID{"c1"}}}, + "failed": {End: RunFailedEnd{Reason: ReasonProviderFailure, Failure: RunFailure{Class: FailureProvider}}}, + } + for key, fact := range cases { + raw, err := json.Marshal(fact) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if len(m) != 1 || m[key] == nil { + t.Fatalf("%s wire = %s, want single %q key", key, raw, key) + } + var back RunEnded + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("%s: %v", key, err) + } + if fmt.Sprint(back.End) != fmt.Sprint(fact.End) { + t.Fatalf("%s round trip = %+v, want %+v", key, back.End, fact.End) + } + } + for name, raw := range map[string]string{ + "no variant": `{}`, + "two variants": `{"completed":{},"stopped":{"reason":"cancelled"}}`, + "legacy flat": `{"status":1}`, + "stopped no reason": `{"stopped":{}}`, + } { + var back RunEnded + if err := json.Unmarshal([]byte(raw), &back); err == nil { + t.Fatalf("%s accepted: %s", name, raw) + } + } +} diff --git a/agent/run/command.go b/agent/run/command.go new file mode 100644 index 0000000..c6d4592 --- /dev/null +++ b/agent/run/command.go @@ -0,0 +1,237 @@ +package run + +// AgentCommand is the intent submitted through Runtime.Commit for an existing +// Run. Accepting one command constitutes one transition (RUN-MCH-3). The +// interface is sealed: only the variants below exist. Commands may carry +// transient content bodies (frozen request, model result, tool output); the +// facts they produce keep only digests (RUN-WIR-4). +type AgentCommand interface{ agentCommand() } + +// AgentInput is a queue-safe input: a stable ID plus an immutable payload. +// Queue item references, priority, order, claims and leases stay in the host. +type AgentInput struct { + ID InputID `json:"id"` + Payload CanonicalJSON `json:"payload"` +} + +// NextStep creates the AcceptInput command. +func NextStep(input AgentInput) AcceptInput { return AcceptInput{Input: input} } + +// PrepareModelRequest freezes the next model request. Its CommandID is +// derived from the loaded Revision, which is also its concurrency control. +// Request is the transient body; the fact keeps RequestDigest and the Runtime +// stores the body in the FrozenValueStore. +type PrepareModelRequest struct { + StepID StepID `json:"stepId"` + Model ModelRef `json:"model"` + Request ModelRequest `json:"request"` + RequestDigest Digest `json:"requestDigest"` + InputIDs []InputID `json:"inputIds,omitempty"` + PlanningToken PlanningToken `json:"planningToken,omitempty"` + Tools []ToolSpec `json:"tools,omitempty"` + ToolsDigest Digest `json:"toolsDigest"` +} + +func (PrepareModelRequest) agentCommand() {} + +// WithdrawPreparedStep discards a Prepared ModelStep whose frozen request +// predates inputs that have since been accepted; the Run returns to Open so +// the next Prepare includes them. Legal only while PendingInputs is non-empty. +type WithdrawPreparedStep struct { + StepID StepID `json:"stepId"` +} + +func (WithdrawPreparedStep) agentCommand() {} + +// StartModelExecution takes execution ownership of a Prepared ModelStep. +type StartModelExecution struct { + StepID StepID `json:"stepId"` + // Claim binds this start command to the Loop execution attempt. It is + // included in the command digest and must be retained for transport retry. + Claim ExecutionClaim `json:"claim"` +} + +func (StartModelExecution) agentCommand() {} + +// RecoverModelExecution releases or recovers model execution: no provider +// result was accepted, so the same frozen request may be prepared for another +// attempt. Legal sources: the current grant holder, or the Runtime's own +// lease-expiry recovery. +type RecoverModelExecution struct { + StepID StepID `json:"stepId"` + // Claim identifies the execution attempt being recovered. Durable recovery + // records use the same claim that was accepted by StartModelExecution. + Claim ExecutionClaim `json:"claim"` +} + +func (RecoverModelExecution) agentCommand() {} + +// SubmitModelResult submits one complete model result with its tool-call +// bindings. Requires the model start grant. +type SubmitModelResult struct { + StepID StepID `json:"stepId"` + Result ModelResult `json:"result"` + Calls []ToolCallBinding `json:"calls,omitempty"` + Scheduling ToolScheduling `json:"scheduling,omitzero"` +} + +func (SubmitModelResult) agentCommand() {} + +// SubmitModelFailure submits the final failure of one model call. Requires +// the model start grant. +type SubmitModelFailure struct { + StepID StepID `json:"stepId"` + Failure StepFailure `json:"failure"` +} + +func (SubmitModelFailure) agentCommand() {} + +type ModelRejectDisposition uint8 + +const ( + // ModelRejectRetry records the malformed result and returns the same frozen + // ModelStep to Prepared for another execution attempt. + ModelRejectRetry ModelRejectDisposition = iota + // ModelRejectFailRun records the malformed result and fails the Run in the + // same transition. + ModelRejectFailRun +) + +// RejectModelResult records a structurally malformed model result: usage is +// accumulated, the step's reject counter is incremented, and Disposition +// decides whether the same frozen request retries or the Run fails. Requires +// the model start grant. +type RejectModelResult struct { + StepID StepID `json:"stepId"` + Usage Usage `json:"usage"` + Failure StepFailure `json:"failure"` + Disposition ModelRejectDisposition `json:"disposition,omitempty"` +} + +func (RejectModelResult) agentCommand() {} + +// StartToolCall takes execution ownership of one Pending tool call. +type StartToolCall struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + // Claim binds this start command to the Loop execution attempt. + Claim ExecutionClaim `json:"claim"` +} + +func (StartToolCall) agentCommand() {} + +// SubmitToolResult submits one successful tool execution. Requires that +// call's start grant. +type SubmitToolResult struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + Result ToolExecutionResult `json:"result"` +} + +func (SubmitToolResult) agentCommand() {} + +// SubmitToolFailure submits a known or unknown tool failure. A known failure +// on a Pending call uses an empty grant; a failure on an Executing call +// requires that call's grant. Unknown outcome records ToolCallFailed for +// that Executing call and leaves the Run active. +type SubmitToolFailure struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + Failure ToolFailure `json:"failure"` + Outcome ToolFailureOutcome `json:"outcome"` +} + +func (SubmitToolFailure) agentCommand() {} + +// ApproveToolCall approves a Waiting(Approval) call. ResponseDigest must be +// DigestToolResponseDecision(ResponseApproval, ResponseDecisionApproved, ""). +type ApproveToolCall struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` +} + +func (ApproveToolCall) agentCommand() {} + +// RejectToolCall rejects a Waiting(Approval or ExternalResponse) call. +// Approval rejection is ToolCallFailed{Known, permission_denied}. +// ExternalResponse rejection is ToolCallFailed{Known, response_rejected}. +// ResponseDigest must be DigestToolResponseDecision(waiting kind, +// ResponseDecisionRejected, Reason). +type RejectToolCall struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` + Reason string `json:"reason,omitempty"` +} + +func (RejectToolCall) agentCommand() {} + +// SubmitToolResponse completes a Waiting(ExternalResponse) call with the +// external answer. ResponseDigest must be DigestToolResponsePayload(Payload). +type SubmitToolResponse struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` + Payload CanonicalJSON `json:"payload"` +} + +func (SubmitToolResponse) agentCommand() {} + +// CancelRun stops a non-terminal Run as a business cancellation. Hosts must +// commit this before cancelling the Loop's context (RUN-LOP-5). +type CancelRun struct { + Reason RunReason `json:"reason,omitempty"` +} + +func (CancelRun) agentCommand() {} + +// AcceptInput appends one input to PendingInputs. Legal in every non-terminal +// state (Open, ModelStep, ToolStep, Waiting); the input is consumed by the next +// Prepare. Idempotent per (RunID, InputID) with identical payload. +type AcceptInput struct { + Input AgentInput `json:"input"` +} + +func (AcceptInput) agentCommand() {} + +// commandType returns the wire discriminator for a sealed command variant. +func commandType(c AgentCommand) string { + switch c.(type) { + case PrepareModelRequest: + return "prepare_model_request" + case WithdrawPreparedStep: + return "withdraw_prepared_step" + case StartModelExecution: + return "start_model_execution" + case RecoverModelExecution: + return "recover_model_execution" + case SubmitModelResult: + return "submit_model_result" + case SubmitModelFailure: + return "submit_model_failure" + case RejectModelResult: + return "reject_model_result" + case StartToolCall: + return "start_tool_call" + case SubmitToolResult: + return "submit_tool_result" + case SubmitToolFailure: + return "submit_tool_failure" + case ApproveToolCall: + return "approve_tool_call" + case RejectToolCall: + return "reject_tool_call" + case SubmitToolResponse: + return "submit_tool_response" + case CancelRun: + return "cancel_run" + case AcceptInput: + return "accept_input" + default: + return "" + } +} diff --git a/agent/run/commit.go b/agent/run/commit.go new file mode 100644 index 0000000..30ae783 --- /dev/null +++ b/agent/run/commit.go @@ -0,0 +1,235 @@ +package run + +import ( + "errors" + "fmt" +) + +type DecisionKind uint8 + +const ( + DecisionApply DecisionKind = iota + DecisionConflict + DecisionStale + DecisionTerminal +) + +// CommitDecision is EvaluateCommit's verdict. The Runtime maps rejections +// onto the sentinel errors: Conflict -> ErrCommandConflict, Stale -> +// ErrStaleRuntime, Terminal -> ErrRunTerminal. +type CommitDecision struct { + Kind DecisionKind + NewState MachineState + Facts []Fact + // Reject carries the precondition failure for Conflict/Stale/Terminal. + Reject error +} + +// ValidateEnvelope is step 1 of RUN-CMT-3: identity and schema. Envelopes are +// only built by Protocol.BuildEnvelope (RUN-WIR-3), so there is no per-commit +// self-verification of the command bytes. +func ValidateEnvelope(env *CommandEnvelope, proto Protocol) error { + if env.SessionID == "" || env.RunID == "" || env.ID == "" { + return errors.New("agent: commit: empty SessionID, RunID or CommandID") + } + if err := proto.ready(); err != nil { + return err + } + if env.SchemaVersion != proto.Version() { + return fmt.Errorf("agent: commit: command schema %d does not match run schema %d", env.SchemaVersion, proto.Version()) + } + return nil +} + +// EvaluateCommit is the pure evaluation every Runtime runs inside the Session +// Writer after the replay lookup (RUN-CMT-3 steps 4-8). Execution ownership is +// Session-level (RUN-CMT-6), so there is no per-target authorization: a +// command against a target whose state does not admit it is Stale. +// +//nolint:gocritic // hugeParam: public pure commit evaluator keeps state/request as value protocol inputs. +func EvaluateCommit(cur MachineState, position RunPosition, req CommitRequest, proto Protocol) (CommitDecision, error) { + env := req.Command + if env.RunID != cur.RunID { + return CommitDecision{}, fmt.Errorf("agent: commit: command run %q does not match authority run %q", env.RunID, cur.RunID) + } + if err := ValidateEnvelope(&env, proto); err != nil { + return CommitDecision{}, err + } + // A start claim is part of the command identity (RUN-WIR-1). + switch cmd := env.Command.(type) { + case StartModelExecution: + if cmd.Claim == "" { + return CommitDecision{Kind: DecisionConflict, Reject: errors.New("agent: commit: model start requires an execution claim")}, nil + } + case StartToolCall: + if cmd.Claim == "" { + return CommitDecision{Kind: DecisionConflict, Reject: errors.New("agent: commit: tool start requires an execution claim")}, nil + } + case RecoverModelExecution: + if cmd.Claim == "" { + return CommitDecision{Kind: DecisionConflict, Reject: errors.New("agent: commit: model recovery requires an execution claim")}, nil + } + } + // Derived-identity families must use their derived CommandID (RUN-WIR-3): + // the derivation is the idempotency index, so a caller-minted random ID + // cannot bypass duplicate detection. + if err := checkDerivedCommandID(&env, req.Base); err != nil { + return CommitDecision{Kind: DecisionConflict, Reject: err}, nil + } + + // Terminal absorbs non-duplicate commands (replay was handled before). + if cur.Status.Terminal() { + return CommitDecision{Kind: DecisionTerminal, Reject: ErrRunTerminal}, nil + } + + // Base: PrepareModelRequest is the only hard-CAS command (RUN-CMT-4). + if _, plan := env.Command.(PrepareModelRequest); plan && req.Base != position { + return CommitDecision{Kind: DecisionStale, Reject: ErrStaleRuntime}, nil + } + + // Step 7: Decide once, fold with Evolve. + facts, err := proto.Decide(cur, env.Command) + if err != nil { + switch { + case errors.Is(err, ErrRunTerminal): + return CommitDecision{Kind: DecisionTerminal, Reject: err}, nil + case errors.Is(err, ErrStaleRuntime): + return CommitDecision{Kind: DecisionStale, Reject: err}, nil + case errors.Is(err, ErrCommandConflict): + return CommitDecision{Kind: DecisionConflict, Reject: err}, nil + default: + // Precondition failures against the current state are stale from + // the caller's perspective: reload and rederive. + return CommitDecision{Kind: DecisionStale, Reject: err}, nil + } + } + if cmd, ok := env.Command.(PrepareModelRequest); ok { + if len(facts) == 0 { + return CommitDecision{}, errors.New("agent: commit: prepare produced no facts") + } + prepared, ok := facts[0].(ModelStepPrepared) + if !ok { + return CommitDecision{}, errors.New("agent: commit: prepare did not produce ModelStepPrepared") + } + wantStep := DeriveModelStepID(env.RunID, env.ID, prepared.BindingDigest) + if cmd.StepID != wantStep { + return CommitDecision{Kind: DecisionStale, Reject: fmt.Errorf("prepare: StepID %q does not match derived StepID %q", cmd.StepID, wantStep)}, nil + } + } + + state := cur + detached := make([]Fact, len(facts)) + for i, f := range facts { + // Detach every fact before it is folded: Decide forwards fields from + // the caller's command and the decision must not carry caller-owned + // mutable objects across the Runtime boundary. + f, err = snapshotFact(f) + if err != nil { + return CommitDecision{}, err + } + state, err = proto.Evolve(state, f) + if err != nil { + return CommitDecision{}, err + } + detached[i] = f + } + return CommitDecision{Kind: DecisionApply, NewState: state, Facts: detached}, nil +} + +// checkDerivedCommandID enforces the derived-identity rules of RUN-WIR-3. +func checkDerivedCommandID(env *CommandEnvelope, base RunPosition) error { + var want CommandID + switch cmd := env.Command.(type) { + case PrepareModelRequest: + want = DeriveModelRequestCommandID(env.RunID, base) + case AcceptInput: + want = DeriveInputCommandID(env.RunID, cmd.Input.ID) + case WithdrawPreparedStep: + want = DeriveWithdrawCommandID(env.RunID, cmd.StepID) + case ApproveToolCall: + want = DeriveResponseCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.ResponseID) + case RejectToolCall: + want = DeriveResponseCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.ResponseID) + case SubmitToolResponse: + want = DeriveResponseCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.ResponseID) + case StartModelExecution: + want = DeriveStartCommandID(env.RunID, cmd.StepID, "", cmd.Claim) + case StartToolCall: + want = DeriveStartCommandID(env.RunID, cmd.StepID, cmd.CallID, cmd.Claim) + case RecoverModelExecution: + want = DeriveModelRecoveryCommandID(env.RunID, cmd.StepID, cmd.Claim) + default: + return nil + } + if want != "" && env.ID != want { + return fmt.Errorf("agent: commit: %s requires its derived CommandID", env.Type) + } + return nil +} + +// IsStart reports whether c begins an execution attempt. +func IsStart(c AgentCommand) bool { + switch c.(type) { + case StartModelExecution, StartToolCall: + return true + } + return false +} + +// CommandClaim returns the ExecutionClaim a start or recovery command carries. +func CommandClaim(c AgentCommand) ExecutionClaim { + switch cmd := c.(type) { + case StartModelExecution: + return cmd.Claim + case StartToolCall: + return cmd.Claim + case RecoverModelExecution: + return cmd.Claim + } + return "" +} + +// Recovery is one takeover disposition command with its derived identity. +type Recovery struct { + Command AgentCommand + ID CommandID +} + +// RecoveryCommands lists the takeover dispositions of every Executing target +// in state (RUN-CMT-7): an Executing model step recovers to Prepared; each +// Executing tool call settles as Unknown. Pending and Waiting calls are left +// alone. claim is the takeover claim of the new owner. +func RecoveryCommands(state *MachineState, claim ExecutionClaim) []Recovery { + if state.Status.Terminal() { + return nil + } + switch cur := state.Current.(type) { + case ModelStep: + if cur.Status != ModelExecuting { + return nil + } + return []Recovery{{ + Command: RecoverModelExecution{StepID: cur.RefValue.ID, Claim: claim}, + ID: DeriveModelRecoveryCommandID(state.RunID, cur.RefValue.ID, claim), + }} + case ToolStep: + var out []Recovery + for _, call := range cur.Calls { + if call.Status != ToolExecuting { + continue + } + out = append(out, Recovery{ + Command: SubmitToolFailure{ + StepID: cur.RefValue.ID, + CallID: call.CallID, + Failure: ToolFailure{Class: FailureEffectUnknown, Message: "owner process lost before settlement"}, + Outcome: ToolOutcomeUnknown, + }, + ID: DeriveToolRecoveryCommandID(state.RunID, cur.RefValue.ID, call.CallID, claim), + }) + } + return out + default: + return nil + } +} diff --git a/agent/run/creation.go b/agent/run/creation.go new file mode 100644 index 0000000..768db18 --- /dev/null +++ b/agent/run/creation.go @@ -0,0 +1,73 @@ +package run + +import ( + "context" + "errors" + "fmt" + "unicode/utf8" + + "github.com/felinics/twilight/agent/es" +) + +// NewRun is the immutable, versioned creation data for a Run. RunID is +// caller-supplied so retries retain a stable identity. Owner and Attempt name +// the upper-level entity this Run serves and its ordinal under it; Run stores +// them and never interprets them. +type NewRun struct { + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + Owner OwnerID `json:"owner,omitempty"` + Attempt uint32 `json:"attempt,omitempty"` + CausationID es.CausationID `json:"causationId,omitempty"` +} + +// BuildNewRun constructs a current-version Run creation value with no owner. +func BuildNewRun(runID RunID, causationID es.CausationID) (NewRun, error) { + return BuildNewRunFor(runID, "", 0, causationID) +} + +// BuildNewRunFor constructs a current-version Run creation value for one +// attempt under owner. +func BuildNewRunFor(runID RunID, owner OwnerID, attempt uint32, causationID es.CausationID) (NewRun, error) { + run := NewRun{SchemaVersion: SchemaVersion1, RunID: runID, Owner: owner, Attempt: attempt, CausationID: causationID} + if err := ValidateNewRun(run); err != nil { + return NewRun{}, err + } + return run, nil +} + +// ValidateNewRun verifies version support and textual identity encoding. +func ValidateNewRun(run NewRun) error { + if run.RunID == "" { + return errors.New("agent: new run: empty RunID") + } + if !utf8.ValidString(string(run.RunID)) { + return errors.New("agent: new run: RunID is not valid UTF-8") + } + if !utf8.ValidString(string(run.Owner)) { + return errors.New("agent: new run: Owner is not valid UTF-8") + } + if !utf8.ValidString(string(run.CausationID)) { + return errors.New("agent: new run: CausationID is not valid UTF-8") + } + if run.SchemaVersion != SchemaVersion1 { + return fmt.Errorf("agent: new run: unsupported schema version %d", run.SchemaVersion) + } + return nil +} + +var ( + // ErrRunNotFound reports an operation addressed a RunID not in the Session. + ErrRunNotFound = errors.New("agent: run not found") +) + +// CheckContext avoids locking when cancellation already makes an operation +// inapplicable. Context is intentionally not retained by the Runtime. +func CheckContext(ctx context.Context) error { + if ctx == nil { + return errors.New("agent: runtime: nil context") + } + return ctx.Err() +} + +func checkContext(ctx context.Context) error { return CheckContext(ctx) } diff --git a/agent/run/creation_test.go b/agent/run/creation_test.go new file mode 100644 index 0000000..4e7963c --- /dev/null +++ b/agent/run/creation_test.go @@ -0,0 +1,33 @@ +package run + +import ( + "testing" + + "github.com/felinics/twilight/agent/es" +) + +func mustNewRun(t testing.TB, id RunID, cause es.CausationID) NewRun { + t.Helper() + run, err := BuildNewRun(id, cause) + if err != nil { + t.Fatal(err) + } + return run +} + +func TestNewRunValidation(t *testing.T) { + created := mustNewRun(t, "run-1", "session-1") + if created.SchemaVersion != SchemaVersion1 { + t.Fatalf("schema = %d", created.SchemaVersion) + } + for _, candidate := range []NewRun{ + {SchemaVersion: SchemaVersion1}, + {SchemaVersion: 99, RunID: "run-1"}, + {SchemaVersion: SchemaVersion1, RunID: RunID(string([]byte{0xff}))}, + {SchemaVersion: SchemaVersion1, RunID: "run-1", CausationID: es.CausationID(string([]byte{0xff}))}, + } { + if err := ValidateNewRun(candidate); err == nil { + t.Fatalf("invalid NewRun accepted: %+v", candidate) + } + } +} diff --git a/agent/run/decide.go b/agent/run/decide.go new file mode 100644 index 0000000..9d6e8aa --- /dev/null +++ b/agent/run/decide.go @@ -0,0 +1,636 @@ +package run + +import ( + "errors" + "fmt" +) + +// Machine errors. EvaluateCommit maps rejection reasons onto these; Decide +// returns them directly when a command's preconditions fail against the +// current state. +var ( + ErrCommandConflict = errors.New("agent: command identity conflict") + ErrStaleRuntime = errors.New("agent: stale runtime revision or grant") + ErrRunTerminal = errors.New("agent: run is terminal") +) + +// rejectionf wraps a precondition failure that is not one of the sentinel +// errors; EvaluateCommit surfaces it as-is. +func rejectionf(format string, args ...any) error { + return fmt.Errorf("agent: reject: "+format, args...) +} + +//nolint:gocritic // hugeParam: v1 Decide is value-based. +func decideV1(s MachineState, c AgentCommand) ([]Fact, error) { + if s.Status.Terminal() { + return nil, ErrRunTerminal + } + switch cmd := c.(type) { + case PrepareModelRequest: + return decidePrepareModelRequest(&s, &cmd) + case WithdrawPreparedStep: + return decideWithdrawPreparedStep(&s, cmd) + case StartModelExecution: + return decideStartModelExecution(&s, cmd) + case RecoverModelExecution: + return decideRecoverModelExecution(&s, cmd) + case SubmitModelResult: + return decideSubmitModelResult(&s, &cmd) + case SubmitModelFailure: + return decideSubmitModelFailure(&s, cmd) + case RejectModelResult: + return decideRejectModelResult(&s, &cmd) + case StartToolCall: + return decideStartToolCall(&s, cmd) + case SubmitToolResult: + return decideSubmitToolResult(&s, cmd) + case SubmitToolFailure: + return decideSubmitToolFailure(&s, cmd) + case ApproveToolCall: + return decideApproveToolCall(&s, cmd) + case RejectToolCall: + return decideRejectToolCall(&s, &cmd) + case SubmitToolResponse: + return decideSubmitToolResponse(&s, &cmd) + case CancelRun: + return decideCancelRun(&s, cmd) + case AcceptInput: + return decideAcceptInput(&s, cmd) + default: + return nil, rejectionf("unknown command variant %T", c) + } +} + +// --- rule 1: PrepareModelRequest --- + +func decidePrepareModelRequest(s *MachineState, cmd *PrepareModelRequest) ([]Fact, error) { + if !atOpen(s.Current) { + return nil, rejectionf("prepare: run is not at Open") + } + if cmd.StepID == "" { + return nil, rejectionf("prepare: empty StepID") + } + if cmd.Model == "" { + return nil, rejectionf("prepare: empty model") + } + if ModelRef(cmd.Request.Model) != cmd.Model { + return nil, rejectionf("prepare: request model %q does not match command model %q", cmd.Request.Model, cmd.Model) + } + // InputIDs must match PendingInputs completely and in current order. + if len(cmd.InputIDs) != len(s.PendingInputs) { + return nil, rejectionf("prepare: InputIDs must consume all %d pending inputs, got %d", len(s.PendingInputs), len(cmd.InputIDs)) + } + for i, id := range cmd.InputIDs { + if s.PendingInputs[i].ID != id { + return nil, rejectionf("prepare: InputIDs[%d]=%q does not match pending input %q", i, id, s.PendingInputs[i].ID) + } + } + // Tools must correspond one-to-one, in order, with the provider tool + // definitions inside the frozen request. The spec keeps only the digest; + // the body stays in the request. + if len(cmd.Tools) != len(cmd.Request.Tools) { + return nil, rejectionf("prepare: %d ToolSpecs for %d request tools", len(cmd.Tools), len(cmd.Request.Tools)) + } + for i, spec := range cmd.Tools { + if spec.Name == "" || spec.Name != cmd.Request.Tools[i].Name { + return nil, rejectionf("prepare: ToolSpec[%d] %q does not match request tool %q", i, spec.Name, cmd.Request.Tools[i].Name) + } + wantDigest, err := digestToolDefinitionV1(cmd.Request.Tools[i]) + if err != nil { + return nil, err + } + if spec.DefinitionDigest != wantDigest { + return nil, rejectionf("prepare: ToolSpec[%d] definition digest mismatch", i) + } + } + wantReq, err := digestRequestV1(cmd.Request) + if err != nil { + return nil, err + } + if cmd.RequestDigest != wantReq { + return nil, rejectionf("prepare: request digest mismatch") + } + wantTools, err := digestToolSpecsV1(cmd.Tools) + if err != nil { + return nil, err + } + if cmd.ToolsDigest != wantTools { + return nil, rejectionf("prepare: tools digest mismatch") + } + binding, err := digestModelStepBindingV1(cmd.Model, cmd.RequestDigest, cmd.ToolsDigest) + if err != nil { + return nil, err + } + return []Fact{ModelStepPrepared{ + StepID: cmd.StepID, + Model: cmd.Model, + RequestDigest: cmd.RequestDigest, + InputIDs: cmd.InputIDs, + Tools: cmd.Tools, + ToolsDigest: cmd.ToolsDigest, + BindingDigest: binding, + }}, nil +} + +// --- rule 1b: WithdrawPreparedStep --- + +func decideWithdrawPreparedStep(s *MachineState, cmd WithdrawPreparedStep) ([]Fact, error) { + ms, err := currentModelStep(s, cmd.StepID) + if err != nil { + return nil, err + } + if ms.Status != ModelPrepared { + return nil, rejectionf("withdraw: step is not Prepared") + } + if len(s.PendingInputs) == 0 { + return nil, rejectionf("withdraw: no pending inputs; the prepared request is still complete") + } + return []Fact{ModelStepWithdrawn{StepID: cmd.StepID}}, nil +} + +// --- rule 2: StartModelExecution / RecoverModelExecution --- + +func currentModelStep(s *MachineState, step StepID) (*ModelStep, error) { + ms, ok := s.Current.(ModelStep) + if !ok { + return nil, rejectionf("no current ModelStep") + } + if ms.RefValue.ID != step { + return nil, rejectionf("step %q is not the current ModelStep %q", step, ms.RefValue.ID) + } + return &ms, nil +} + +func decideStartModelExecution(s *MachineState, cmd StartModelExecution) ([]Fact, error) { + ms, err := currentModelStep(s, cmd.StepID) + if err != nil { + return nil, err + } + if ms.Status != ModelPrepared { + return nil, rejectionf("start model: step is not Prepared") + } + return []Fact{ModelStepStarted{StepID: cmd.StepID}}, nil +} + +func decideRecoverModelExecution(s *MachineState, cmd RecoverModelExecution) ([]Fact, error) { + ms, err := currentModelStep(s, cmd.StepID) + if err != nil { + return nil, err + } + if ms.Status != ModelExecuting { + return nil, rejectionf("recover model: step is not Executing") + } + return []Fact{ModelStepRecovered{StepID: cmd.StepID}}, nil +} + +// --- rule 3: SubmitModelResult --- + +func decideSubmitModelResult(s *MachineState, cmd *SubmitModelResult) ([]Fact, error) { + ms, err := currentModelStep(s, cmd.StepID) + if err != nil { + return nil, err + } + if ms.Status != ModelExecuting { + return nil, rejectionf("model result: step is not Executing") + } + resultDigest, err := digestModelResultV1(cmd.Result) + if err != nil { + return nil, err + } + completed := ModelStepCompleted{StepID: cmd.StepID, Usage: cmd.Result.Usage, FinishReason: cmd.Result.FinishReason, ResultDigest: resultDigest} + + // The result's own tool calls decide whether a ToolStep opens; gating on + // the caller-supplied bindings would let zero bindings silently complete + // a run whose model asked for tools. + if len(cmd.Result.ToolCalls) == 0 { + if len(cmd.Calls) != 0 { + return nil, rejectionf("model result: %d bindings for a result with no tool calls", len(cmd.Calls)) + } + // Inputs that arrived during this step keep the Run alive: the next + // Prepare consumes them. Only an empty queue ends the Run. + if len(s.PendingInputs) > 0 { + return []Fact{completed}, nil + } + return []Fact{completed, RunEnded{End: RunCompletedEnd{}}}, nil + } + bindings, err := checkToolCallBindings(ms, cmd) + if err != nil { + return nil, err + } + opened, err := openToolStep(s.RunID, cmd.StepID, bindings, cmd.Scheduling) + if err != nil { + return nil, err + } + return []Fact{completed, opened}, nil +} + +// checkToolCallBindings validates the caller's bindings one-to-one against +// the model result and the frozen ToolSpecs (RUN-MCH-2) and returns them with +// Response cleared, ready for openToolStep to derive. +func checkToolCallBindings(ms *ModelStep, cmd *SubmitModelResult) ([]ToolCallBinding, error) { + if len(cmd.Calls) != len(cmd.Result.ToolCalls) { + return nil, rejectionf("model result: %d bindings for %d tool calls", len(cmd.Calls), len(cmd.Result.ToolCalls)) + } + specByName := make(map[string]ToolSpec, len(ms.Tools)) + for _, spec := range ms.Tools { + specByName[spec.Name] = spec + } + seen := make(map[CallID]bool, len(cmd.Calls)) + bindings := make([]ToolCallBinding, len(cmd.Calls)) + for i := range cmd.Calls { + b := cmd.Calls[i] + rc := &cmd.Result.ToolCalls[i] + if want := DeriveCallID(cmd.StepID, i); b.CallID != want { + return nil, rejectionf("model result: binding %d CallID %q is not the derived id %q", i, b.CallID, want) + } + if b.ProviderCallID != rc.ToolCallID { + return nil, rejectionf("model result: binding %d ProviderCallID %q does not match result call %q", i, b.ProviderCallID, rc.ToolCallID) + } + if seen[b.CallID] { + return nil, rejectionf("model result: duplicate CallID %q", b.CallID) + } + seen[b.CallID] = true + if err := checkBindingAgainstResult(&b, rc, specByName); err != nil { + return nil, err + } + b.Response = nil // derived by openToolStep; callers leave it empty + bindings[i] = b + } + return bindings, nil +} + +// checkBindingAgainstResult accepts only a binding for the tool the model +// actually named, with the arguments the model actually produced. A known +// tool must match its frozen ToolSpec; an unknown one stays an unresolved +// DirectExecution binding that StartToolCalls records as a lookup failure. +func checkBindingAgainstResult(b *ToolCallBinding, rc *ModelToolCall, specByName map[string]ToolSpec) error { + if spec, known := specByName[rc.ToolName]; known { + if b.ToolRef != spec.Ref { + return rejectionf("model result: binding %q ToolRef %q does not match frozen spec ref %q for tool %q", b.CallID, b.ToolRef, spec.Ref, rc.ToolName) + } + if b.DefinitionDigest != spec.DefinitionDigest { + return rejectionf("model result: binding %q definition digest does not match frozen ToolSpec", b.CallID) + } + if b.Policy != spec.Policy { + return rejectionf("model result: binding %q policy does not match frozen ToolSpec", b.CallID) + } + } else { + if string(b.ToolRef) != rc.ToolName { + return rejectionf("model result: binding %q ToolRef %q does not match result tool %q", b.CallID, b.ToolRef, rc.ToolName) + } + if b.Policy != DirectExecution || b.DefinitionDigest != "" { + return rejectionf("model result: unresolved binding %q must be DirectExecution with empty digest", b.CallID) + } + } + wantArgs, argsCanonical := canonicalArgumentsForCompare(rc.Input) + if !argsCanonical { + return rejectionf("model result: call %q input is not frozen canonical JSON", b.CallID) + } + if !b.Arguments.Equal(wantArgs) { + return rejectionf("model result: binding %q arguments do not match the model result", b.CallID) + } + wantBinding, err := digestToolCallBinding(b.CallID, b.DefinitionDigest, b.Policy, b.Arguments) + if err != nil { + return err + } + if b.BindingDigest != wantBinding { + return rejectionf("model result: binding %q binding digest mismatch", b.CallID) + } + return nil +} + +// openToolStep derives the ToolStep identity from the ordered binding set, +// attaches a ResponseRequest to every call whose policy waits, and freezes +// the scheduling (RUN-LOP-1). +func openToolStep(runID RunID, source StepID, bindings []ToolCallBinding, scheduling ToolScheduling) (ToolStepOpened, error) { + setDigest, err := digestBindingSet(bindings) + if err != nil { + return ToolStepOpened{}, err + } + toolStepID := DeriveToolStepID(source, setDigest) + for i := range bindings { + kind, waits := responseKindForPolicy(bindings[i].Policy) + if !waits { + continue + } + reqDigest, err := digestToolCallBinding(bindings[i].CallID, bindings[i].DefinitionDigest, bindings[i].Policy, bindings[i].Arguments) + if err != nil { + return ToolStepOpened{}, err + } + bindings[i].Response = &ResponseRequest{ + RunID: runID, + StepID: toolStepID, + CallID: bindings[i].CallID, + ID: DeriveResponseID(runID, toolStepID, bindings[i].CallID, kind), + Kind: kind, + Payload: bindings[i].Arguments, + RequestDigest: reqDigest, + } + } + normalized, err := normalizeToolScheduling(scheduling) + if err != nil { + return ToolStepOpened{}, rejectionf("model result: %v", err) + } + return ToolStepOpened{ + StepID: toolStepID, + Source: source, + BindingSetDigest: setDigest, + Calls: bindings, + Scheduling: normalized, + }, nil +} + +// canonicalArgumentsForCompare canonicalizes a model result's tool input for +// cross-checking a binding. The second return is false when the command did +// not carry a frozen JSON-stable tool input; Runtime commits reject that shape. +func canonicalArgumentsForCompare(input any) (CanonicalJSON, bool) { + got, err := canonicalToolArguments(input) + if err != nil { + return CanonicalJSON{}, false + } + return got, true +} + +// --- rule 4: SubmitModelFailure --- + +func decideSubmitModelFailure(s *MachineState, cmd SubmitModelFailure) ([]Fact, error) { + ms, err := currentModelStep(s, cmd.StepID) + if err != nil { + return nil, err + } + if ms.Status != ModelExecuting { + return nil, rejectionf("model failure: step is not Executing") + } + if cmd.Failure.Class == "" { + return nil, rejectionf("model failure: empty failure class") + } + return []Fact{RunEnded{End: RunFailedEnd{ + Reason: ReasonProviderFailure, + Failure: RunFailure{Class: cmd.Failure.Class, Message: cmd.Failure.Message}, + }}}, nil +} + +// --- rule 5: RejectModelResult --- + +func decideRejectModelResult(s *MachineState, cmd *RejectModelResult) ([]Fact, error) { + ms, err := currentModelStep(s, cmd.StepID) + if err != nil { + return nil, err + } + if ms.Status != ModelExecuting { + return nil, rejectionf("reject model result: step is not Executing") + } + rejected := ModelStepRejected{StepID: cmd.StepID, Usage: cmd.Usage, Failure: cmd.Failure} + switch cmd.Disposition { + case ModelRejectRetry: + return []Fact{rejected}, nil + case ModelRejectFailRun: + return []Fact{rejected, RunEnded{End: RunFailedEnd{ + Reason: ReasonMalformedModel, + Failure: RunFailure{Class: FailureMalformedModel, Message: cmd.Failure.Message}, + }}}, nil + default: + return nil, rejectionf("reject model result: unknown disposition %d", cmd.Disposition) + } +} + +// --- rules 6-8: tool call lifecycle --- + +func currentToolStep(s *MachineState, step StepID) (*ToolStep, error) { + ts, ok := s.Current.(ToolStep) + if !ok { + return nil, rejectionf("no current ToolStep") + } + if ts.RefValue.ID != step { + return nil, rejectionf("step %q is not the current ToolStep %q", step, ts.RefValue.ID) + } + return &ts, nil +} + +func decideStartToolCall(s *MachineState, cmd StartToolCall) ([]Fact, error) { + ts, err := currentToolStep(s, cmd.StepID) + if err != nil { + return nil, err + } + i := ts.callIndex(cmd.CallID) + if i < 0 { + return nil, rejectionf("start tool: unknown call %q", cmd.CallID) + } + if ts.Calls[i].Status != ToolPending { + return nil, rejectionf("start tool: call %q is not Pending", cmd.CallID) + } + return []Fact{ToolCallStarted{StepID: cmd.StepID, CallID: cmd.CallID}}, nil +} + +func decideSubmitToolResult(s *MachineState, cmd SubmitToolResult) ([]Fact, error) { + ts, err := currentToolStep(s, cmd.StepID) + if err != nil { + return nil, err + } + i := ts.callIndex(cmd.CallID) + if i < 0 { + return nil, rejectionf("tool result: unknown call %q", cmd.CallID) + } + if ts.Calls[i].Status != ToolExecuting { + return nil, rejectionf("tool result: call %q is not Executing", cmd.CallID) + } + outputDigest, err := digestToolOutputV1(cmd.Result.Output) + if err != nil { + return nil, err + } + return []Fact{ToolCallCompleted{StepID: cmd.StepID, CallID: cmd.CallID, OutputDigest: outputDigest}}, nil +} + +func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, error) { + ts, err := currentToolStep(s, cmd.StepID) + if err != nil { + return nil, err + } + i := ts.callIndex(cmd.CallID) + if i < 0 { + return nil, rejectionf("tool failure: unknown call %q", cmd.CallID) + } + call := ts.Calls[i] + if cmd.Failure.Class == "" { + if cmd.Outcome == ToolOutcomeUnknown { + cmd.Failure.Class = FailureEffectUnknown + } else { + return nil, rejectionf("tool failure: empty failure class") + } + } + switch cmd.Outcome { + case ToolOutcomeKnown: + if call.Status != ToolPending && call.Status != ToolExecuting { + return nil, rejectionf("tool failure: call %q is not Pending or Executing", cmd.CallID) + } + facts := []Fact{ToolCallFailed{StepID: cmd.StepID, CallID: cmd.CallID, Failure: cmd.Failure, Outcome: ToolOutcomeKnown}} + return facts, nil + case ToolOutcomeUnknown: + if call.Status != ToolExecuting { + return nil, rejectionf("tool failure: unknown outcome requires Executing call") + } + failure := cmd.Failure + if failure.Class == "" { + failure.Class = FailureEffectUnknown + } + if failure.Class != FailureEffectUnknown { + return nil, rejectionf("tool failure: unknown outcome must use %s", FailureEffectUnknown) + } + return []Fact{ToolCallFailed{StepID: cmd.StepID, CallID: cmd.CallID, Failure: failure, Outcome: ToolOutcomeUnknown}}, nil + default: + return nil, rejectionf("tool failure: unknown outcome value %d", cmd.Outcome) + } +} + +// --- rules 9-10: responses --- + +// waitingCall checks that call is Waiting on the current ToolStep for the +// given response kind and ID. +func waitingCall(s *MachineState, step StepID, call CallID, kind ResponseKind, resp ResponseID) error { + ts, err := currentToolStep(s, step) + if err != nil { + return err + } + i := ts.callIndex(call) + if i < 0 { + return rejectionf("response: unknown call %q", call) + } + c := ts.Calls[i] + if c.Status != ToolWaiting || c.Waiting == nil { + return rejectionf("response: call %q is not Waiting", call) + } + if c.Waiting.Kind != kind { + return rejectionf("response: call %q expects kind %q, got %q", call, c.Waiting.Kind, kind) + } + if c.Waiting.ID != resp { + return rejectionf("response: call %q expects ResponseID %q, got %q", call, c.Waiting.ID, resp) + } + return nil +} + +func decideApproveToolCall(s *MachineState, cmd ApproveToolCall) ([]Fact, error) { + if err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseApproval, cmd.ResponseID); err != nil { + return nil, err + } + wantDigest, err := digestToolResponseDecisionV1(ResponseApproval, ResponseDecisionApproved, "") + if err != nil { + return nil, err + } + if cmd.ResponseDigest != wantDigest { + return nil, rejectionf("response: approval digest mismatch") + } + return []Fact{ToolCallApproved(cmd)}, nil +} + +func decideRejectToolCall(s *MachineState, cmd *RejectToolCall) ([]Fact, error) { + // Reject closes a Waiting call of either kind as a Known failure: + // approval rejection and external-response abandonment ("the answer is + // never coming") share one exit. Waiting -> Failed(Known) is legal; + // without this, an abandoned ask-user call would strand the run with + // CancelRun as the only escape. + ts, err := currentToolStep(s, cmd.StepID) + if err != nil { + return nil, err + } + i := ts.callIndex(cmd.CallID) + if i < 0 { + return nil, rejectionf("response: unknown call %q", cmd.CallID) + } + c := ts.Calls[i] + if c.Status != ToolWaiting || c.Waiting == nil { + return nil, rejectionf("response: call %q is not Waiting", cmd.CallID) + } + if c.Waiting.ID != cmd.ResponseID { + return nil, rejectionf("response: call %q expects ResponseID %q, got %q", cmd.CallID, c.Waiting.ID, cmd.ResponseID) + } + wantDigest, err := digestToolResponseDecisionV1(c.Waiting.Kind, ResponseDecisionRejected, cmd.Reason) + if err != nil { + return nil, err + } + if cmd.ResponseDigest != wantDigest { + return nil, rejectionf("response: rejection digest mismatch") + } + class := FailurePermissionDenied + if c.Waiting.Kind == ResponseExternal { + class = FailureResponseRejected + } + facts := []Fact{ToolCallFailed{ + StepID: cmd.StepID, + CallID: cmd.CallID, + Failure: ToolFailure{Class: class, Message: cmd.Reason}, + Outcome: ToolOutcomeKnown, + }} + return facts, nil +} + +func decideSubmitToolResponse(s *MachineState, cmd *SubmitToolResponse) ([]Fact, error) { + if err := waitingCall(s, cmd.StepID, cmd.CallID, ResponseExternal, cmd.ResponseID); err != nil { + return nil, err + } + wantDigest, err := digestToolResponsePayloadV1(cmd.Payload) + if err != nil { + return nil, err + } + if cmd.ResponseDigest != wantDigest { + return nil, rejectionf("response: answer payload digest mismatch") + } + return []Fact{ToolCallAnswered{StepID: cmd.StepID, CallID: cmd.CallID, ResponseID: cmd.ResponseID, ResponseDigest: cmd.ResponseDigest}}, nil +} + +// --- rules 12-13: cancel and input --- + +func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { + // CancelRun always records RunStopped(cancelled). + if cmd.Reason != "" && cmd.Reason != ReasonCancelled { + return nil, rejectionf("cancel: reason must be empty or %q", ReasonCancelled) + } + facts := unknownExecutingCalls(s, ToolFailure{Class: FailureEffectUnknown, Message: "execution cancelled before settlement"}) + uncertain := make([]CallID, 0, len(facts)) + for _, f := range facts { + if failed, ok := f.(ToolCallFailed); ok { + uncertain = append(uncertain, failed.CallID) + } + } + var uncertainModel StepID + if ms, ok := s.Current.(ModelStep); ok && ms.Status == ModelExecuting { + uncertainModel = ms.RefValue.ID + } + facts = append(facts, RunEnded{End: RunStoppedEnd{ + Reason: ReasonCancelled, + UncertainCalls: uncertain, + UncertainModel: uncertainModel, + }}) + return facts, nil +} + +// unknownExecutingCalls records every Executing call that CancelRun is about +// to abandon. Waiting and already-settled calls are left unchanged. +func unknownExecutingCalls(s *MachineState, failure ToolFailure) []Fact { + ts, ok := s.Current.(ToolStep) + if !ok { + return nil + } + facts := make([]Fact, 0, len(ts.Calls)) + for i := range ts.Calls { + if ts.Calls[i].Status != ToolExecuting { + continue + } + facts = append(facts, ToolCallFailed{StepID: ts.RefValue.ID, CallID: ts.Calls[i].CallID, Failure: failure, Outcome: ToolOutcomeUnknown}) + } + return facts +} + +// decideAcceptInput queues an input in any non-terminal state (RUN-MCH-4): +// PendingInputs is the durable mid-run input queue, consumed by the next +// Prepare. A Prepared step with a non-empty queue is withdrawn by Next. +func decideAcceptInput(s *MachineState, cmd AcceptInput) ([]Fact, error) { + if cmd.Input.ID == "" { + return nil, rejectionf("accept input: empty InputID") + } + for _, in := range s.PendingInputs { + if in.ID == cmd.Input.ID { + return nil, ErrCommandConflict + } + } + return []Fact{InputAccepted(cmd)}, nil +} diff --git a/agent/run/evolve.go b/agent/run/evolve.go new file mode 100644 index 0000000..800cb80 --- /dev/null +++ b/agent/run/evolve.go @@ -0,0 +1,467 @@ +package run + +import ( + "errors" + "fmt" +) + +// evolveV1 is the fold semantics for the pre-release SchemaVersion1. It first +// checks that the fact is a legal transition from s (fold and recovery must +// defend themselves without access to commands), then applies it. +// +//nolint:gocritic // hugeParam: v1 fold body intentionally preserves value-state semantics. +func evolveV1(s MachineState, f Fact) (MachineState, error) { + if err := guardFactV1(&s, f); err != nil { + return s, err + } + switch fact := f.(type) { + case RunCreated: + return applyRunCreated(&fact), nil + case ModelStepPrepared: + return applyModelStepPrepared(s, &fact), nil + case ModelStepWithdrawn: + return applyModelStepWithdrawn(s), nil + case ModelStepStarted: + return applyModelStatus(s, ModelExecuting, Usage{}, false), nil + case ModelStepRecovered: + return applyModelStatus(s, ModelPrepared, Usage{}, false), nil + case ModelStepRejected: + return applyModelStatus(s, ModelPrepared, fact.Usage, true), nil + case ModelStepCompleted: + return applyModelStepCompleted(s, &fact), nil + case ToolStepOpened: + return applyToolStepOpened(s, &fact), nil + case ToolCallStarted: + return applyCall(s, fact.CallID, func(c *ToolCallState) { c.Status = ToolExecuting }), nil + case ToolCallApproved: + return applyCall(s, fact.CallID, func(c *ToolCallState) { c.Status, c.Waiting = ToolPending, nil }), nil + case ToolCallCompleted: + return applyCall(s, fact.CallID, func(c *ToolCallState) { + c.Status, c.Result, c.Waiting = ToolCompleted, &ToolCallResult{OutputDigest: fact.OutputDigest}, nil + }), nil + case ToolCallAnswered: + return applyCall(s, fact.CallID, func(c *ToolCallState) { + c.Status, c.Result, c.Waiting = ToolCompleted, &ToolCallResult{OutputDigest: fact.ResponseDigest}, nil + }), nil + case ToolCallFailed: + return applyCall(s, fact.CallID, func(c *ToolCallState) { + c.Status, c.Failure, c.Waiting = ToolFailed, &ToolCallFailure{Failure: fact.Failure, Outcome: fact.Outcome}, nil + }), nil + case InputAccepted: + return applyInputAccepted(s, &fact), nil + case RunEnded: + return applyRunEnded(s, &fact), nil + default: + return s, fmt.Errorf("agent: evolve: unknown fact variant %T", f) + } +} + +// --- apply: mechanical folds; guardFactV1 has established every precondition --- + +func applyRunCreated(fact *RunCreated) MachineState { + return MachineState{RunID: fact.RunID, Owner: fact.Owner, Attempt: fact.Attempt, Status: RunActive, Current: Open{}} +} + +func applyModelStepPrepared(s MachineState, fact *ModelStepPrepared) MachineState { + s.Current = ModelStep{ + RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingDigest}, + RequestDigest: fact.RequestDigest, + Model: fact.Model, + Tools: fact.Tools, + ToolsDigest: fact.ToolsDigest, + Status: ModelPrepared, + } + s.ModelSteps++ + s.PendingInputs = nil + return s +} + +// applyModelStepWithdrawn discards the Prepared step: it never executed, so it +// does not count as a model step. PendingInputs are untouched. +func applyModelStepWithdrawn(s MachineState) MachineState { + s.Current = Open{} + s.ModelSteps-- + return s +} + +// applyModelStatus moves the current ModelStep to status, adding usage and +// counting a reject when the fact was a rejection. +func applyModelStatus(s MachineState, status ModelStepStatus, usage Usage, rejected bool) MachineState { + ms := s.Current.(ModelStep) //nolint:errcheck // guard established Current is this ModelStep + ms.Status = status + if rejected { + ms.Rejects++ + } + s.Current = ms + s.Usage = s.Usage.Add(usage) + return s +} + +func applyModelStepCompleted(s MachineState, fact *ModelStepCompleted) MachineState { + s.Usage = s.Usage.Add(fact.Usage) + s.Current = Open{} + return s +} + +func applyToolStepOpened(s MachineState, fact *ToolStepOpened) MachineState { + calls := make([]ToolCallState, len(fact.Calls)) + for i, b := range fact.Calls { + calls[i] = ToolCallState{ + CallID: b.CallID, + ProviderCallID: b.ProviderCallID, + ToolRef: b.ToolRef, + DefinitionDigest: b.DefinitionDigest, + BindingDigest: b.BindingDigest, + Arguments: b.Arguments, + Policy: b.Policy, + Status: ToolPending, + } + if b.Response != nil { + w := *b.Response + calls[i].Status, calls[i].Waiting = ToolWaiting, &w + } + } + s.Current = ToolStep{ + RefValue: StepRef{RunID: s.RunID, ID: fact.StepID, Digest: fact.BindingSetDigest}, + Source: fact.Source, + Calls: calls, + Scheduling: fact.Scheduling, + } + return s +} + +// applyCall mutates one call of the current ToolStep and closes the step when +// every call has reached Completed or Failed. +func applyCall(s MachineState, callID CallID, mutate func(*ToolCallState)) MachineState { + ts := s.Current.(ToolStep) //nolint:errcheck // guard established Current is this ToolStep + calls := append([]ToolCallState(nil), ts.Calls...) + mutate(&calls[ts.callIndex(callID)]) + ts.Calls = calls + if allToolCallsTerminal(calls) { + s.LastToolStep = &ts + s.Current = Open{} + } else { + s.Current = ts + } + return s +} + +func applyInputAccepted(s MachineState, fact *InputAccepted) MachineState { + s.PendingInputs = append(append([]AgentInput(nil), s.PendingInputs...), fact.Input) + return s +} + +func applyRunEnded(s MachineState, fact *RunEnded) MachineState { + status, reason, failure := endProjection(fact.End) + s.Status = status + s.Current = nil + result := &RunResult{Status: status, Reason: reason, Failure: failure, Usage: s.Usage} + if stopped, ok := fact.End.(RunStoppedEnd); ok { + result.UncertainCalls = append([]CallID(nil), stopped.UncertainCalls...) + result.UncertainModel = stopped.UncertainModel + } + s.Result = result + return s +} + +// --- guards: one per fact. Each names the legal source state and the +// self-consistency the fact must carry. --- + +func guardFactV1(s *MachineState, f Fact) error { + if created, ok := f.(RunCreated); ok { + return guardRunCreated(s, &created) + } + if s.RunID == "" { + return errors.New("agent: evolve: fact before RunCreated") + } + if s.Status.Terminal() { + return errors.New("agent: evolve: fact after terminal state") + } + switch fact := f.(type) { + case ModelStepPrepared: + return guardModelStepPrepared(s, &fact) + case ModelStepWithdrawn: + if err := requireModelStep(s, fact.StepID, ModelPrepared); err != nil { + return err + } + if len(s.PendingInputs) == 0 { + return errors.New("agent: evolve: model step withdrawn without pending inputs") + } + return nil + case ModelStepStarted: + return requireModelStep(s, fact.StepID, ModelPrepared) + case ModelStepRecovered: + return requireModelStep(s, fact.StepID, ModelExecuting) + case ModelStepRejected: + return requireModelStep(s, fact.StepID, ModelExecuting) + case ModelStepCompleted: + if fact.ResultDigest == "" { + return errors.New("agent: evolve: model step completed without result digest") + } + return requireModelStep(s, fact.StepID, ModelExecuting) + case ToolStepOpened: + return guardToolStepOpened(s, &fact) + case ToolCallStarted: + _, err := requireCall(s, fact.StepID, fact.CallID, ToolPending) + return err + case ToolCallApproved: + return guardToolCallApproved(s, &fact) + case ToolCallCompleted: + if fact.OutputDigest == "" { + return errors.New("agent: evolve: tool call completed without output digest") + } + _, err := requireCall(s, fact.StepID, fact.CallID, ToolExecuting) + return err + case ToolCallAnswered: + return guardToolCallAnswered(s, &fact) + case ToolCallFailed: + return guardToolCallFailed(s, &fact) + case InputAccepted: + return guardInputAccepted(s, &fact) + case RunEnded: + return validateRunEnd(fact.End) + default: + return fmt.Errorf("agent: evolve: unknown fact variant %T", f) + } +} + +func requireOpen(s *MachineState, what string) error { + if !atOpen(s.Current) { + return fmt.Errorf("agent: evolve: %s while run is not at Open", what) + } + return nil +} + +// requireModelStep checks that Current is ModelStep stepID in status. +func requireModelStep(s *MachineState, stepID StepID, status ModelStepStatus) error { + ms, ok := s.Current.(ModelStep) + if !ok || ms.RefValue.ID != stepID || ms.Status != status { + return fmt.Errorf("agent: evolve: model step %q is not %s", stepID, status) + } + return nil +} + +// requireCall returns the call when Current is ToolStep stepID and the call +// is in one of statuses. +func requireCall(s *MachineState, stepID StepID, callID CallID, statuses ...ToolCallStatus) (ToolCallState, error) { + ts, ok := s.Current.(ToolStep) + if !ok || ts.RefValue.ID != stepID { + return ToolCallState{}, fmt.Errorf("agent: evolve: tool step %q is not current", stepID) + } + i := ts.callIndex(callID) + if i < 0 { + return ToolCallState{}, fmt.Errorf("agent: evolve: unknown call %q", callID) + } + call := ts.Calls[i] + for _, want := range statuses { + if call.Status == want { + return call, nil + } + } + return ToolCallState{}, fmt.Errorf("agent: evolve: tool call %q is %s", callID, call.Status) +} + +func guardRunCreated(s *MachineState, fact *RunCreated) error { + if s.RunID != "" || s.Current != nil || s.Status != RunActive { + return errors.New("agent: evolve: run created on a non-zero state") + } + if fact.RunID == "" { + return errors.New("agent: evolve: run created with empty RunID") + } + if fact.SchemaVersion != SchemaVersion1 { + return fmt.Errorf("agent: evolve: run created with unsupported schema version %d", fact.SchemaVersion) + } + return nil +} + +// guardInputAccepted admits an input in any non-terminal state; only a +// duplicate pending InputID is illegal. +func guardInputAccepted(s *MachineState, fact *InputAccepted) error { + if fact.Input.ID == "" { + return errors.New("agent: evolve: input accepted with empty InputID") + } + for _, in := range s.PendingInputs { + if in.ID == fact.Input.ID { + // Decide rejects this and an exact replay never reaches Evolve, so + // a persisted duplicate is a corrupt log, not an idempotent append. + return fmt.Errorf("agent: evolve: input %q already pending", fact.Input.ID) + } + } + return nil +} + +func guardModelStepPrepared(s *MachineState, fact *ModelStepPrepared) error { + if err := requireOpen(s, "model step prepared"); err != nil { + return err + } + if fact.StepID == "" || fact.Model == "" || fact.RequestDigest == "" || fact.ToolsDigest == "" || fact.BindingDigest == "" { + return errors.New("agent: evolve: model step prepared is missing identity or digest") + } + // v1 preparation is the atomic consumption boundary for pending inputs. + // A persisted fact must name every pending input exactly once, in queue + // order; accepting a subset or an invented ID would make replay diverge + // from the command that created this frozen request. + if len(fact.InputIDs) != len(s.PendingInputs) { + return fmt.Errorf("agent: evolve: model step prepared input IDs do not completely consume pending inputs: got %d, want %d", len(fact.InputIDs), len(s.PendingInputs)) + } + for i, input := range s.PendingInputs { + if fact.InputIDs[i] != input.ID { + return fmt.Errorf("agent: evolve: model step prepared input ID at position %d = %q, want pending input %q", i, fact.InputIDs[i], input.ID) + } + } + // The request body is not in the fact; its digest is checked against the + // body by Decide and by the FrozenValueStore on read. Tools and binding + // digests are recomputable from the fact and must agree. + if d, err := digestToolSpecsV1(fact.Tools); err != nil || d != fact.ToolsDigest { + return errors.New("agent: evolve: model step prepared tools digest mismatch") + } + if d, err := digestModelStepBindingV1(fact.Model, fact.RequestDigest, fact.ToolsDigest); err != nil || d != fact.BindingDigest { + return errors.New("agent: evolve: model step prepared binding digest mismatch") + } + return nil +} + +func guardToolStepOpened(s *MachineState, fact *ToolStepOpened) error { + if err := requireOpen(s, "tool step opened"); err != nil { + return err + } + if len(fact.Calls) == 0 { + return errors.New("agent: evolve: tool step opened with no calls") + } + if fact.StepID == "" || fact.Source == "" || fact.BindingSetDigest == "" { + return errors.New("agent: evolve: tool step is missing identity or digest") + } + if _, err := normalizeToolScheduling(fact.Scheduling); err != nil { + return fmt.Errorf("agent: evolve: tool step scheduling: %w", err) + } + // BindingSetDigest is defined over the ordered call bindings before + // derived response requests are attached. Recompute that exact input. + base := make([]ToolCallBinding, len(fact.Calls)) + for i := range fact.Calls { + base[i] = fact.Calls[i] + base[i].Response = nil + } + if d, err := digestBindingSet(base); err != nil || d != fact.BindingSetDigest || DeriveToolStepID(fact.Source, fact.BindingSetDigest) != fact.StepID { + return errors.New("agent: evolve: tool step binding digest mismatch") + } + seen := make(map[CallID]struct{}, len(fact.Calls)) + for i := range fact.Calls { + if err := guardToolCallBinding(s.RunID, fact.StepID, &fact.Calls[i], seen); err != nil { + return err + } + } + return nil +} + +func guardToolCallBinding(runID RunID, stepID StepID, call *ToolCallBinding, seen map[CallID]struct{}) error { + if call.CallID == "" { + return errors.New("agent: evolve: tool step contains empty CallID") + } + if _, dup := seen[call.CallID]; dup { + return fmt.Errorf("agent: evolve: duplicate CallID %q", call.CallID) + } + seen[call.CallID] = struct{}{} + if call.ToolRef == "" || call.BindingDigest == "" || call.Arguments.IsZero() { + return fmt.Errorf("agent: evolve: tool call %q is missing binding data", call.CallID) + } + want, err := DigestToolCallBinding(call.CallID, call.DefinitionDigest, call.Policy, call.Arguments) + if err != nil || want != call.BindingDigest { + return fmt.Errorf("agent: evolve: tool call %q binding digest mismatch", call.CallID) + } + if call.Response == nil { + return nil + } + kind, ok := responseKindForPolicy(call.Policy) + if !ok { + return fmt.Errorf("agent: evolve: direct call %q cannot carry a response request", call.CallID) + } + return validateResponseRequest(call.Response, runID, stepID, call.CallID, kind, call.Arguments, want) +} + +func guardToolCallApproved(s *MachineState, fact *ToolCallApproved) error { + call, err := requireCall(s, fact.StepID, fact.CallID, ToolWaiting) + if err != nil { + return err + } + if err := requireWaitingFor(&call, ResponseApproval, fact.ResponseID); err != nil { + return err + } + if d, err := digestToolResponseDecisionV1(ResponseApproval, ResponseDecisionApproved, ""); err != nil || d != fact.ResponseDigest { + return fmt.Errorf("agent: evolve: tool call %q approval digest mismatch", fact.CallID) + } + return nil +} + +func guardToolCallAnswered(s *MachineState, fact *ToolCallAnswered) error { + call, err := requireCall(s, fact.StepID, fact.CallID, ToolWaiting) + if err != nil { + return err + } + if err := requireWaitingFor(&call, ResponseExternal, fact.ResponseID); err != nil { + return err + } + if fact.ResponseDigest == "" { + return fmt.Errorf("agent: evolve: tool call %q answered without response digest", fact.CallID) + } + return nil +} + +func requireWaitingFor(call *ToolCallState, kind ResponseKind, responseID ResponseID) error { + if call.Waiting == nil || call.Waiting.Kind != kind { + return fmt.Errorf("agent: evolve: tool call %q is not waiting for %s", call.CallID, kind) + } + if call.Waiting.ID != responseID { + return fmt.Errorf("agent: evolve: tool call %q response ID mismatch", call.CallID) + } + return nil +} + +func guardToolCallFailed(s *MachineState, fact *ToolCallFailed) error { + call, err := requireCall(s, fact.StepID, fact.CallID, ToolPending, ToolExecuting, ToolWaiting) + if err != nil { + return err + } + if fact.Outcome == ToolOutcomeUnknown && call.Status != ToolExecuting { + return fmt.Errorf("agent: evolve: unknown outcome requires Executing call, %q is %s", fact.CallID, call.Status) + } + // Class/outcome agreement is the rule ValidateToolCallState applies to + // the folded state; check it here so the error names the fact. + return ValidateToolCallState(ToolCallState{CallID: fact.CallID, Status: ToolFailed, + Failure: &ToolCallFailure{Failure: fact.Failure, Outcome: fact.Outcome}}) +} + +func responseKindForPolicy(p ResponsePolicy) (ResponseKind, bool) { + switch p { + case ApprovalRequired: + return ResponseApproval, true + case ExternalResponse: + return ResponseExternal, true + default: + return "", false + } +} + +func validateResponseRequest(req *ResponseRequest, runID RunID, stepID StepID, callID CallID, kind ResponseKind, payload CanonicalJSON, requestDigest Digest) error { + if req.RunID != runID || req.StepID != stepID || req.CallID != callID || req.Kind != kind { + return fmt.Errorf("agent: evolve: response request identity mismatch for call %q", callID) + } + if req.ID == "" || req.ID != DeriveResponseID(runID, stepID, callID, kind) { + return fmt.Errorf("agent: evolve: response request ID mismatch for call %q", callID) + } + if req.RequestDigest != requestDigest || !req.Payload.Equal(payload) { + return fmt.Errorf("agent: evolve: response request payload mismatch for call %q", callID) + } + return nil +} + +func allToolCallsTerminal(calls []ToolCallState) bool { + if len(calls) == 0 { + return false + } + for i := range calls { + if !calls[i].Status.Terminal() { + return false + } + } + return true +} diff --git a/agent/run/fact.go b/agent/run/fact.go new file mode 100644 index 0000000..b8c2466 --- /dev/null +++ b/agent/run/fact.go @@ -0,0 +1,336 @@ +package run + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/felinics/twilight/agent/es" +) + +// Fact is one committed outcome produced by Machine.Decide. Facts are wrapped +// as AgentEvents; Machine.Evolve folds them mechanically (RUN-MCH-3). The +// interface is sealed: only the variants below exist. Facts carry execution +// state and content digests only; content bodies travel with the companion +// (RUN-WIR-4). +type Fact interface{ fact() } + +// RunCreated is the first fact of a Run (RUN-NEW-1). Folding it onto the zero +// MachineState yields the Revision-0 state; a second RunCreated is an error. +type RunCreated struct { + SchemaVersion uint16 `json:"schemaVersion"` + RunID RunID `json:"runId"` + Owner OwnerID `json:"owner,omitempty"` + Attempt uint32 `json:"attempt,omitempty"` + CausationID es.CausationID `json:"causationId,omitempty"` +} + +func (RunCreated) fact() {} + +// ModelStepPrepared establishes the frozen ModelStep and consumes the listed +// pending inputs. The request body is not in the fact: RequestDigest names it +// in the FrozenValueStore. BindingDigest (model + request + tools) is computed +// by Decide and carried in the fact: Evolve folds it verbatim, never +// recomputes (fact self-containment, RUN-MCH-3). +type ModelStepPrepared struct { + StepID StepID `json:"stepId"` + Model ModelRef `json:"model"` + RequestDigest Digest `json:"requestDigest"` + InputIDs []InputID `json:"inputIds,omitempty"` + Tools []ToolSpec `json:"tools,omitempty"` + ToolsDigest Digest `json:"toolsDigest"` + BindingDigest Digest `json:"bindingDigest"` +} + +func (ModelStepPrepared) fact() {} + +// ModelStepWithdrawn: Prepared -> Open. The frozen request was never sent; +// inputs arrived while it was Prepared and the next Prepare must include them. +type ModelStepWithdrawn struct { + StepID StepID `json:"stepId"` +} + +func (ModelStepWithdrawn) fact() {} + +// ModelStepStarted: Prepared -> Executing. +type ModelStepStarted struct { + StepID StepID `json:"stepId"` +} + +func (ModelStepStarted) fact() {} + +// ModelStepRecovered: Executing -> Prepared, no accepted result. +type ModelStepRecovered struct { + StepID StepID `json:"stepId"` +} + +func (ModelStepRecovered) fact() {} + +// ModelStepRejected records one structurally malformed result: usage is +// accumulated, Rejects is incremented, the step returns to Prepared. +type ModelStepRejected struct { + StepID StepID `json:"stepId"` + Usage Usage `json:"usage"` + Failure StepFailure `json:"failure"` +} + +func (ModelStepRejected) fact() {} + +// ModelStepCompleted accepts one model result: usage is accumulated and +// Current becomes Open. The result body is not in the fact; ResultDigest names +// it and the companion writes the content. The same transition may then open +// a ToolStep or end the Run. +type ModelStepCompleted struct { + StepID StepID `json:"stepId"` + Usage Usage `json:"usage"` + FinishReason FinishReason `json:"finishReason"` + ResultDigest Digest `json:"resultDigest"` +} + +func (ModelStepCompleted) fact() {} + +// ToolStepOpened establishes the ToolStep with its full frozen call set. +// BindingSetDigest is computed by Decide over the ordered pre-Response +// binding set and carried in the fact: Evolve folds it verbatim, so replay +// never recomputes a digest with a different schema version, and +// DeriveToolStepID(Source, BindingSetDigest) == StepID always holds. +type ToolStepOpened struct { + StepID StepID `json:"stepId"` // the new ToolStep + Source StepID `json:"source"` // the completed ModelStep + BindingSetDigest Digest `json:"bindingSetDigest"` + Calls []ToolCallBinding `json:"calls"` + Scheduling ToolScheduling `json:"scheduling,omitzero"` +} + +func (ToolStepOpened) fact() {} + +// ToolCallStarted: Pending -> Executing. +type ToolCallStarted struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` +} + +func (ToolCallStarted) fact() {} + +// ToolCallApproved: Waiting(Approval) -> Pending. +type ToolCallApproved struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` +} + +func (ToolCallApproved) fact() {} + +// ToolCallCompleted: Executing -> Completed. OutputDigest names the tool +// output the companion carries. +type ToolCallCompleted struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + OutputDigest Digest `json:"outputDigest"` +} + +func (ToolCallCompleted) fact() {} + +// ToolCallAnswered: Waiting(ExternalResponse) -> Completed. ResponseDigest is +// the digest of the external answer payload the companion carries. +type ToolCallAnswered struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ResponseID ResponseID `json:"responseId"` + ResponseDigest Digest `json:"responseDigest"` +} + +func (ToolCallAnswered) fact() {} + +// ToolCallFailed: Pending/Executing/Waiting -> Failed(Known/Unknown). +type ToolCallFailed struct { + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + Failure ToolFailure `json:"failure"` + Outcome ToolFailureOutcome `json:"outcome"` +} + +func (ToolCallFailed) fact() {} + +// InputAccepted appends one input to PendingInputs. Legal in every +// non-terminal state; PendingInputs is the durable mid-run input queue. +type InputAccepted struct { + Input AgentInput `json:"input"` +} + +func (InputAccepted) fact() {} + +// RunEnd is the closed set of terminal outcomes. +type RunEnd interface{ runEnd() } + +type RunCompletedEnd struct{} +type RunStoppedEnd struct { + Reason RunReason + UncertainCalls []CallID `json:"uncertainCalls,omitempty"` + UncertainModel StepID `json:"uncertainModel,omitempty"` +} +type RunFailedEnd struct { + Reason RunReason + Failure RunFailure +} + +func (RunCompletedEnd) runEnd() {} +func (RunStoppedEnd) runEnd() {} +func (RunFailedEnd) runEnd() {} + +// RunEnded is the terminal fact. Always the last fact of its transition. +type RunEnded struct { + End RunEnd `json:"-"` +} + +func (RunEnded) fact() {} + +func validateRunEnd(end RunEnd) error { + switch e := end.(type) { + case RunCompletedEnd: + return nil + case RunStoppedEnd: + if e.Reason == "" { + return errors.New("agent: run ended: stopped outcome requires a reason") + } + return nil + case RunFailedEnd: + if e.Reason == "" { + return errors.New("agent: run ended: failed outcome requires a reason") + } + if e.Failure.Class == "" { + return errors.New("agent: run ended: failed outcome requires a failure class") + } + return nil + default: + return fmt.Errorf("agent: run ended: unknown end variant %T", end) + } +} + +func endProjection(end RunEnd) (RunStatus, RunReason, *RunFailure) { + switch e := end.(type) { + case RunCompletedEnd: + return RunCompleted, "", nil + case RunStoppedEnd: + return RunStopped, e.Reason, nil + case RunFailedEnd: + failure := e.Failure + return RunFailed, e.Reason, &failure + default: + return RunActive, "", nil + } +} + +// runEndWire is the tagged-union wire of RunEnded: exactly one variant key is +// present. It mirrors the Go union so the wire cannot express an outcome the +// type system rejects. +type runEndWire struct { + Completed *struct{} `json:"completed,omitempty"` + Stopped *runStoppedEndWire `json:"stopped,omitempty"` + Failed *runFailedEndWire `json:"failed,omitempty"` +} + +type runStoppedEndWire struct { + Reason RunReason `json:"reason"` + UncertainCalls []CallID `json:"uncertainCalls,omitempty"` + UncertainModel StepID `json:"uncertainModel,omitempty"` +} + +type runFailedEndWire struct { + Reason RunReason `json:"reason"` + Failure RunFailure `json:"failure"` +} + +func (r RunEnded) MarshalJSON() ([]byte, error) { + if err := validateRunEnd(r.End); err != nil { + return nil, err + } + var w runEndWire + switch e := r.End.(type) { + case RunCompletedEnd: + w.Completed = &struct{}{} + case RunStoppedEnd: + w.Stopped = &runStoppedEndWire{Reason: e.Reason, UncertainCalls: e.UncertainCalls, UncertainModel: e.UncertainModel} + case RunFailedEnd: + w.Failed = &runFailedEndWire{Reason: e.Reason, Failure: e.Failure} + } + return json.Marshal(w) +} + +func (r *RunEnded) UnmarshalJSON(raw []byte) error { + var w runEndWire + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&w); err != nil { + return err + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return errors.New("agent: run ended: trailing JSON") + } + return err + } + variants := 0 + var end RunEnd + if w.Completed != nil { + variants++ + end = RunCompletedEnd{} + } + if w.Stopped != nil { + variants++ + end = RunStoppedEnd{Reason: w.Stopped.Reason, UncertainCalls: w.Stopped.UncertainCalls, UncertainModel: w.Stopped.UncertainModel} + } + if w.Failed != nil { + variants++ + end = RunFailedEnd{Reason: w.Failed.Reason, Failure: w.Failed.Failure} + } + if variants != 1 { + return fmt.Errorf("agent: run ended: exactly one outcome required, got %d", variants) + } + if err := validateRunEnd(end); err != nil { + return err + } + *r = RunEnded{End: end} + return nil +} + +// factType returns the wire discriminator for a sealed fact variant. +func factType(f Fact) string { + switch f.(type) { + case RunCreated: + return "run_created" + case ModelStepPrepared: + return "model_step_prepared" + case ModelStepWithdrawn: + return "model_step_withdrawn" + case ModelStepStarted: + return "model_step_started" + case ModelStepRecovered: + return "model_step_recovered" + case ModelStepRejected: + return "model_step_rejected" + case ModelStepCompleted: + return "model_step_completed" + case ToolStepOpened: + return "tool_step_opened" + case ToolCallStarted: + return "tool_call_started" + case ToolCallApproved: + return "tool_call_approved" + case ToolCallCompleted: + return "tool_call_completed" + case ToolCallAnswered: + return "tool_call_answered" + case ToolCallFailed: + return "tool_call_failed" + case InputAccepted: + return "input_accepted" + case RunEnded: + return "run_ended" + default: + return "" + } +} diff --git a/agent/run/fold.go b/agent/run/fold.go new file mode 100644 index 0000000..54902f8 --- /dev/null +++ b/agent/run/fold.go @@ -0,0 +1,48 @@ +package run + +import ( + "bytes" + "errors" + "fmt" +) + +// FoldRun rebuilds a MachineState from the complete fact sequence of one Run +// in stream order (RUN-NEW-2): the first fact must be RunCreated, which binds +// the Protocol for every later fact. No Decide, no effects, no replay. +func FoldRun(facts []Fact) (MachineState, error) { + if len(facts) == 0 { + return MachineState{}, errors.New("agent: fold: no facts") + } + created, ok := facts[0].(RunCreated) + if !ok { + return MachineState{}, fmt.Errorf("agent: fold: first fact is %T, want RunCreated", facts[0]) + } + proto, err := ProtocolFor(created.SchemaVersion) + if err != nil { + return MachineState{}, err + } + var state MachineState + for i, f := range facts { + f, err = snapshotFact(f) + if err != nil { + return MachineState{}, err + } + state, err = proto.Evolve(state, f) + if err != nil { + return MachineState{}, fmt.Errorf("agent: fold: fact %d (%s): %w", i, factType(f), err) + } + } + return state, nil +} + +// StatesEquivalent compares two states via their canonical snapshot encoding. +func StatesEquivalent(a, b *MachineState) bool { return statesEquivalent(a, b) } + +func statesEquivalent(a, b *MachineState) bool { + ab, errA := encodeMachineStateV1(a) + bb, errB := encodeMachineStateV1(b) + if errA != nil || errB != nil { + return false + } + return bytes.Equal(ab, bb) +} diff --git a/agent/run/frozen.go b/agent/run/frozen.go new file mode 100644 index 0000000..5e20f3c --- /dev/null +++ b/agent/run/frozen.go @@ -0,0 +1,100 @@ +package run + +import ( + "context" + "errors" + "fmt" + "sync" +) + +// FrozenValueStore is the content-addressed side store for frozen bodies that +// facts name by digest only (RUN-WIR-4): today the ModelRequest of a Prepared +// step. Put is idempotent; a body may be dropped once the step that named it +// has settled, so readers must treat a missing body as a distinct condition. +type FrozenValueStore interface { + Put(ctx context.Context, digest Digest, value []byte) error + Get(ctx context.Context, digest Digest) ([]byte, bool, error) +} + +// ErrFrozenValueMissing reports that a body named by a fact is no longer in +// the FrozenValueStore. Recovery cannot resend the request; the caller decides +// whether to retry the attempt with a fresh plan. +var ErrFrozenValueMissing = errors.New("agent: frozen value missing") + +// MemoryFrozenValues is the in-process FrozenValueStore. Tests that simulate a +// process restart share one instance across Runtimes, as a durable adapter +// would share its table. +type MemoryFrozenValues struct { + mu sync.RWMutex + values map[Digest][]byte +} + +func NewMemoryFrozenValues() *MemoryFrozenValues { + return &MemoryFrozenValues{values: make(map[Digest][]byte)} +} + +func (m *MemoryFrozenValues) Put(ctx context.Context, digest Digest, value []byte) error { + if err := checkContext(ctx); err != nil { + return err + } + if digest == "" { + return errors.New("agent: frozen values: empty digest") + } + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.values[digest]; exists { + return nil + } + m.values[digest] = append([]byte(nil), value...) + return nil +} + +func (m *MemoryFrozenValues) Get(ctx context.Context, digest Digest) ([]byte, bool, error) { + if err := checkContext(ctx); err != nil { + return nil, false, err + } + m.mu.RLock() + defer m.mu.RUnlock() + value, ok := m.values[digest] + if !ok { + return nil, false, nil + } + return append([]byte(nil), value...), true, nil +} + +// Delete drops one body; adapters call it when the naming step has settled. +func (m *MemoryFrozenValues) Delete(digest Digest) { + m.mu.Lock() + delete(m.values, digest) + m.mu.Unlock() +} + +// EncodeFrozenRequest renders the canonical bytes stored for a request and +// verifies they digest to the name the fact will carry. +func EncodeFrozenRequest(req *ModelRequest, want Digest) ([]byte, error) { + got, err := digestRequestV1(*req) + if err != nil { + return nil, err + } + if got != want { + return nil, fmt.Errorf("agent: frozen request: body digest %s does not match %s", got, want) + } + return marshalCanonical(req) +} + +// DecodeFrozenRequest restores a request body and checks it still digests to +// the name it was stored under. +func DecodeFrozenRequest(raw []byte, want Digest) (ModelRequest, error) { + var req ModelRequest + if err := decodeStrictJSON(raw, &req); err != nil { + return ModelRequest{}, fmt.Errorf("agent: frozen request: %w", err) + } + got, err := digestRequestV1(req) + if err != nil { + return ModelRequest{}, err + } + if got != want { + return ModelRequest{}, fmt.Errorf("agent: frozen request: stored body digest %s does not match %s", got, want) + } + return req, nil +} diff --git a/agent/run/ids.go b/agent/run/ids.go new file mode 100644 index 0000000..049a8a8 --- /dev/null +++ b/agent/run/ids.go @@ -0,0 +1,140 @@ +package run + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/session" +) + +type RunID string + +// OwnerID identifies the upper-level entity a Run serves (the Turn, in the +// reference agent). Run stores it and never interprets it. +type OwnerID string +type StepID string +type CallID string +type CommandID string +type ResponseID string +type InputID string +type ToolRef string +type ModelRef string + +// Digest is "sha256:<64 lowercase hex>" over canonical protocol bytes. +// It remains an alias while Run protocol types live in this package. +type Digest = es.Digest + +// PlanningToken is opaque to agent; the application uses it to identify the +// context revision from which a RequestPlan was built. +type PlanningToken string + +// ExecutionClaim is an opaque identity chosen by the execution loop for one +// start command. It lets a caller replay the same start request without +// accidentally acquiring a second execution grant. +type ExecutionClaim string + +func sha256Digest(data []byte) Digest { return es.DigestBytes(data) } + +// namespacedHash derives a stable identifier from a namespace and ordered +// parts. Parts are length-prefixed so no two distinct part lists collide. +func namespacedHash(namespace string, parts ...string) string { + h := sha256.New() + fmt.Fprintf(h, "%d:%s", len(namespace), namespace) + for _, p := range parts { + fmt.Fprintf(h, "%d:%s", len(p), p) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// DeriveModelRequestCommandID derives the CommandID for PrepareModelRequest +// from the Run and the RunPosition the planner loaded (RUN-WIR-3): concurrent +// planners on the same position converge on one command identity. +func DeriveModelRequestCommandID(run RunID, position RunPosition) CommandID { + return CommandID(namespacedHash("twilight/model-request", string(run), fmt.Sprintf("%d", position))) +} + +// DeriveTakeoverClaim is the ExecutionClaim a new Session owner uses for its +// takeover dispositions (RUN-CMT-7): the same owner repeats idempotently, +// distinct owners issue distinct commands. +func DeriveTakeoverClaim(sid session.SessionID, epoch session.Epoch) ExecutionClaim { + return ExecutionClaim(namespacedHash("twilight/run/takeover", string(sid), fmt.Sprintf("%d", epoch))) +} + +// DeriveModelStepID derives the frozen ModelStep identity from the Run, the +// preparing command, and the model-step binding digest (model + request + +// tools). +func DeriveModelStepID(run RunID, cmd CommandID, binding Digest) StepID { + return StepID(namespacedHash("twilight/model-step", string(run), string(cmd), string(binding))) +} + +// DeriveCallID derives the Run-owned identity of one tool call from the +// ModelStep that produced it and the call's position in that step's result. +// The provider's own tool_call_id is kept beside it as ProviderCallID for the +// request round trip; it is not trusted to be unique or non-empty. +func DeriveCallID(source StepID, index int) CallID { + return CallID(namespacedHash("twilight/tool-call", string(source), fmt.Sprintf("%d", index))) +} + +// DeriveToolStepID derives the ToolStep identity from its source ModelStep +// and the binding-set digest over the full ordered call set. +func DeriveToolStepID(source StepID, bindingSet Digest) StepID { + return StepID(namespacedHash("twilight/tool-step", string(source), string(bindingSet))) +} + +// DeriveResponseID derives the stable ResponseID the Machine assigns when it +// creates a Waiting request. One call has at most one outstanding request, so +// (run, step, call, kind) identifies it. +func DeriveResponseID(run RunID, step StepID, call CallID, kind ResponseKind) ResponseID { + return ResponseID(namespacedHash("twilight/response", string(run), string(step), string(call), string(kind))) +} + +// DeriveResponseCommandID derives the CommandID for approval/rejection/answer +// commands: independent ingress processes converge on one command identity +// without coordination. +func DeriveResponseCommandID(run RunID, step StepID, call CallID, resp ResponseID) CommandID { + return CommandID(namespacedHash("twilight/response-command", string(run), string(step), string(call), string(resp))) +} + +// DeriveInputCommandID derives the CommandID for AcceptInput from the Run and +// the InputID. Queue-claim references stay private to the host. +func DeriveInputCommandID(run RunID, input InputID) CommandID { + return CommandID(namespacedHash("twilight/input-command", string(run), string(input))) +} + +// DeriveWithdrawCommandID derives the CommandID of WithdrawPreparedStep: one +// Prepared step is withdrawn at most once, so the identity needs no content. +func DeriveWithdrawCommandID(run RunID, step StepID) CommandID { + return CommandID(namespacedHash("twilight/withdraw-command", string(run), string(step))) +} + +// DeriveStartCommandID derives the CommandID of StartModelExecution (empty +// call) or StartToolCall from the target and the attempt's ExecutionClaim. +// Commit enforces this derivation so a caller-minted ID cannot bypass the +// idempotency index (RUN-WIR-3). +func DeriveStartCommandID(run RunID, step StepID, call CallID, claim ExecutionClaim) CommandID { + return CommandID(namespacedHash("twilight/start-command", string(run), string(step), string(call), string(claim))) +} + +// DeriveSettlementCommandID derives the CommandID of the owner's settlement +// of one execution attempt (model result/failure/reject, tool result/failure). +// One attempt settles once, so the identity needs no content: a replay with +// the same outcome is idempotent, a different outcome is a conflict. +func DeriveSettlementCommandID(run RunID, step StepID, call CallID, claim ExecutionClaim) CommandID { + return CommandID(namespacedHash("twilight/settlement-command", string(run), string(step), string(call), string(claim))) +} + +// DeriveModelRecoveryCommandID derives the stable command identity for +// recovering one model execution attempt. The claim is part of the identity: +// a model step may be started, recovered, and started again, and each attempt +// must have its own recovery record. +func DeriveModelRecoveryCommandID(run RunID, step StepID, claim ExecutionClaim) CommandID { + return CommandID(namespacedHash("twilight/model-recovery", string(run), string(step), string(claim))) +} + +// DeriveToolRecoveryCommandID derives the identity of the Unknown settlement +// a takeover commits for one abandoned tool attempt (RUN-CMT-7). +func DeriveToolRecoveryCommandID(run RunID, step StepID, call CallID, claim ExecutionClaim) CommandID { + return CommandID(namespacedHash("twilight/tool-recovery", string(run), string(step), string(call), string(claim))) +} diff --git a/agent/run/loop/attempt.go b/agent/run/loop/attempt.go new file mode 100644 index 0000000..f0366cb --- /dev/null +++ b/agent/run/loop/attempt.go @@ -0,0 +1,74 @@ +package loop + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + + run "github.com/felinics/twilight/agent/run" +) + +// attempt is one execution attempt this Loop owns. Every command identity of +// the attempt derives from its claim, which lives only in the worker's memory +// (RUN-MCH-3): a crash hands the target to the next owner's takeover. +type attempt struct { + runID run.RunID + stepID run.StepID + callID run.CallID + claim run.ExecutionClaim +} + +func newAttempt(runID run.RunID, stepID run.StepID, callID run.CallID) attempt { + return attempt{runID: runID, stepID: stepID, callID: callID, claim: freshExecutionClaim()} +} + +func (a attempt) startID() run.CommandID { + return run.DeriveStartCommandID(a.runID, a.stepID, a.callID, a.claim) +} + +func (a attempt) settlementID() run.CommandID { + return run.DeriveSettlementCommandID(a.runID, a.stepID, a.callID, a.claim) +} + +func (a attempt) recoveryID() run.CommandID { + return run.DeriveModelRecoveryCommandID(a.runID, a.stepID, a.claim) +} + +// settle commits the owner settlement of an attempt under its derived +// CommandID. A sentinel rejection means the attempt is over (another actor +// moved the target); ownership loss is returned as is. +// +// When the accepted settlement terminates the Run, the terminal RunResult is +// returned: the Runtime already handed back the folded state, so the Loop +// finishes from it instead of reloading a Run the projection no longer holds. +func (l *Loop) settle(ctx context.Context, runtime boundRuntime, events EventSink, a attempt, base run.RunPosition, cmd run.AgentCommand, proto run.Protocol) (*run.RunResult, error) { + id := a.settlementID() + if _, recovering := cmd.(run.RecoverModelExecution); recovering { + id = a.recoveryID() + } + res, err := l.commit(context.WithoutCancel(ctx), runtime, a.runID, id, base, cmd, proto) + if err != nil { + if retriable(err) { + return nil, nil + } + return nil, err + } + l.emitCommitted(ctx, events, runtime.sid, a.runID, res.Events) + if res.Snapshot.State.Status.Terminal() { + return res.Snapshot.State.Result, nil + } + return nil, nil +} + +func freshExecutionClaim() run.ExecutionClaim { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("agent: loop: %v", err)) + } + return run.ExecutionClaim(hex.EncodeToString(b[:])) +} + +// ownershipLost reports the terminal ownership error (RUN-LOP-5). +func ownershipLost(err error) bool { return errors.Is(err, run.ErrOwnershipLost) } diff --git a/agent/run/loop/bound.go b/agent/run/loop/bound.go new file mode 100644 index 0000000..bedbbaa --- /dev/null +++ b/agent/run/loop/bound.go @@ -0,0 +1,27 @@ +package loop + +import ( + "context" + + run "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" +) + +// boundRuntime binds a run.Runtime to the Session one Loop.Run drives, so the +// interpreter stays RunID-addressed internally. +type boundRuntime struct { + rt run.Runtime + sid session.SessionID +} + +func (b boundRuntime) Load(ctx context.Context, runID run.RunID) (run.RuntimeSnapshot, error) { + return b.rt.Load(ctx, b.sid, runID) +} + +func (b boundRuntime) Commit(ctx context.Context, req run.CommitRequest) (run.CommitResult, error) { + return b.rt.Commit(ctx, b.sid, req) +} + +func (b boundRuntime) FrozenRequest(ctx context.Context, digest run.Digest) (run.ModelRequest, error) { + return b.rt.FrozenRequest(ctx, digest) +} diff --git a/agent/run/loop/contract.go b/agent/run/loop/contract.go new file mode 100644 index 0000000..6f6861c --- /dev/null +++ b/agent/run/loop/contract.go @@ -0,0 +1,188 @@ +package loop + +import ( + "context" + "encoding/json" + "errors" + + run "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + + "github.com/felinics/twilight/sdk" +) + +// ErrRunAlreadyRunning identifies a second local driver for the same Run. +// A Loop permits concurrent execution of different Runs and serializes each +// Run locally so every Executing target has one in-process owner (RUN-CMT-6). +var ErrRunAlreadyRunning = errors.New("agent: loop: run already running") + +// RequestPlanner is the port the application injects: it projects application +// context into the next boundary sdk.Request (RUN-LOP-2). Loop freezes it into +// an agent-owned ModelRequest before crossing the Runtime boundary. Planning +// implementations never live in agent. +type RequestPlanner interface { + Plan(context.Context, run.PlanningHint) (RequestPlan, error) +} + +type RequestPlan struct { + Model run.ModelRef + Request sdk.Request + InputIDs []run.InputID + PlanningToken run.PlanningToken + Tools []run.ToolSpec +} + +// ModelCatalog resolves a frozen run.ModelRef into an invoker at execution time; +// provider binding never enters the frozen request. The same ModelRef must +// resolve to equivalent execution semantics for the life of a Run (RUN-LOP-7). +type ModelCatalog interface { + ResolveModel(run.ModelRef) (ModelInvoker, error) +} + +type ModelInvoker interface { + Generate(context.Context, sdk.Request) (sdk.ModelResult, error) +} + +// StreamingModelInvoker is an optional optimization; it must produce the same +// final ModelResult as Generate. +type StreamingModelInvoker interface { + Stream(context.Context, sdk.Request) (sdk.ModelStream, error) +} + +type ToolCatalog interface { + ResolveTool(run.ToolRef) (ExecutableTool, error) +} + +type ToolExecutionRequest struct { + RunID run.RunID + StepID run.StepID + CallID run.CallID + ToolRef run.ToolRef + DefinitionDigest run.Digest + Arguments run.CanonicalJSON + Progress ToolProgressSink +} + +// ExecutableTool is the application-side execution contract (RUN-LOP-1). +type ExecutableTool interface { + Ref() run.ToolRef + Definition() sdk.ToolDefinition + ResponsePolicy() run.ResponsePolicy + // ValidateArguments runs before the start barrier and must not produce + // external effects. + ValidateArguments(run.CanonicalJSON) error + Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome +} + +// ToolExecutionOutcome is sealed: succeeded, failed-known, or unknown. +type ToolExecutionOutcome interface{ toolExecutionOutcome() } + +type ToolExecutionSucceeded struct{ Result run.ToolExecutionResult } + +func (ToolExecutionSucceeded) toolExecutionOutcome() {} + +// ToolExecutionFailed asserts the external effect did NOT complete. +type ToolExecutionFailed struct{ Failure run.ToolFailure } + +func (ToolExecutionFailed) toolExecutionOutcome() {} + +// ToolExecutionUnknown means the effect may or may not have happened. +type ToolExecutionUnknown struct{ Failure run.ToolFailure } + +func (ToolExecutionUnknown) toolExecutionOutcome() {} + +type ToolProgressSink interface { + Publish(context.Context, ToolProgress) +} + +type ToolProgress struct { + Payload json.RawMessage +} + +// ToolExecutionMode is Loop-local until SubmitModelResult snapshots it onto +// ToolStep.Scheduling. +type ToolExecutionMode string + +const ( + ToolExecutionParallel ToolExecutionMode = "parallel" + ToolExecutionSequential ToolExecutionMode = "sequential" +) + +// --- EventSink: realtime observation, never authority (RUN-LOP-6) --- + +type EventSink interface { + Emit(context.Context, Event) error +} + +type EventDurability uint8 + +const ( + EventProvisional EventDurability = iota + EventCommitted +) + +type EventKind string + +const ( + EventAgentCommitted EventKind = "agent_committed" + EventModelTextDelta EventKind = "model_text_delta" + EventModelReasoningDelta EventKind = "model_reasoning_delta" + EventToolProgress EventKind = "tool_progress" + EventToolStarted EventKind = "tool_started" + EventToolCompleted EventKind = "tool_completed" + EventRunFinished EventKind = "run_finished" +) + +type Event struct { + Session session.SessionID + RunID run.RunID + StepID run.StepID + CallID run.CallID + // Sequence orders provisional observations within one stream. Committed + // observations use the Session Seq for authority ordering. + Sequence uint64 + Kind EventKind + Durability EventDurability + Payload json.RawMessage + // Committed is set for an EventAgentCommitted observation: the accepted + // group (run facts, companion, attach); nil for provisional. + Committed []session.SessionEvent +} + +// ExecutionPolicy is host-owned loop policy. ToolExecution and MaxParallel +// are snapshotted onto ToolStep at SubmitModelResult and then frozen. +// OnMalformedModelResult is not persisted. +type ExecutionPolicy struct { + ToolExecution ToolExecutionMode + // OnMalformedModelResult chooses the disposition recorded for a malformed + // provider result. A nil handler fails the Run; retries must be explicit. + OnMalformedModelResult func(run.ModelStep, run.StepFailure) run.ModelRejectDisposition + // MaxParallel bounds local tool workers. Zero means all eligible calls in + // the current batch may run concurrently. + MaxParallel int +} + +type LoopDisposition uint8 + +const ( + LoopWaiting LoopDisposition = iota + LoopFinished +) + +type LoopResult struct { + Disposition LoopDisposition + // Reason is execution_recovery when ExecutionRecovery is true; otherwise empty. + Reason WaitReason + // ExecutionRecovery is true when NeedsRecovery(state) is true after this + // Loop has no further executable effect: a ModelStep is Executing, or a + // ToolStep has Executing calls and no Pending calls. Under Session-level + // ownership this only happens before the owner's takeover disposition. + ExecutionRecovery bool + Result *run.RunResult +} + +type WaitReason string + +const ( + ExecutionRecovery WaitReason = "execution_recovery" +) diff --git a/agent/run/loop/events.go b/agent/run/loop/events.go new file mode 100644 index 0000000..7aa4f67 --- /dev/null +++ b/agent/run/loop/events.go @@ -0,0 +1,69 @@ +package loop + +import ( + "context" + "encoding/json" + "sync" + + run "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" +) + +type serializedEventSink struct { + sink EventSink + mu *sync.Mutex +} + +func (s *serializedEventSink) Emit(ctx context.Context, event Event) error { + if s == nil || s.sink == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.sink.Emit(ctx, event) +} + +func (l *Loop) emitCommitted(ctx context.Context, events EventSink, sid session.SessionID, runID run.RunID, committed []session.SessionEvent) { + if events == nil || len(committed) == 0 { + return + } + _ = events.Emit(ctx, Event{ + Session: sid, + RunID: runID, + Kind: EventAgentCommitted, + Durability: EventCommitted, + Committed: append([]session.SessionEvent(nil), committed...), + }) +} + +type progressSink struct { + events EventSink + run run.RunID + step run.StepID + call run.CallID + seq uint64 + mu sync.Mutex +} + +func (p *progressSink) Publish(ctx context.Context, progress ToolProgress) { + if p.events == nil { + return + } + p.mu.Lock() + p.seq++ + seq := p.seq + p.mu.Unlock() + _ = p.events.Emit(ctx, Event{ + RunID: p.run, StepID: p.step, CallID: p.call, + Sequence: seq, Kind: EventToolProgress, Durability: EventProvisional, + Payload: progress.Payload, + }) +} + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte("null") + } + return b +} diff --git a/agent/run/loop/helpers_test.go b/agent/run/loop/helpers_test.go new file mode 100644 index 0000000..114ad70 --- /dev/null +++ b/agent/run/loop/helpers_test.go @@ -0,0 +1,127 @@ +package loop + +import ( + "context" + "testing" + "time" + + . "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" +) + +const ( + testModel ModelRef = "m-1" + testSession session.SessionID = "s-1" +) + +func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } + +// nopCompanion writes no conversation content; Loop tests exercise the Run +// facts only. +type nopCompanion struct{} + +func (nopCompanion) Version() string { return "test/nop" } +func (nopCompanion) Map(CompanionRequest) ([]ModuleEvent, error) { return nil, nil } + +// testStack is the minimal Session stack a Loop test drives: kernel Memory +// Store, the run module, one owner process (Writers) and a Runtime with a +// no-op companion. +type testStack struct { + store *session.MemoryStore + registry *extension.Registry + writers extension.Writers + runtime *runmod.Runtime + now func() time.Time +} + +func newTestStack(t testing.TB, now func() time.Time) *testStack { + t.Helper() + if now == nil { + now = time.Now + } + store := session.NewMemoryStore() + registry, err := extension.BuildRegistry(session.ProtocolVersion1, runmod.Module) + if err != nil { + t.Fatal(err) + } + if _, err := store.Create(context.Background(), session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: testSession}); err != nil { + t.Fatal(err) + } + s := &testStack{store: store, registry: registry, now: now} + s.open(t) + return s +} + +// open starts a new owner process over the same store, superseding a previous +// one that is still open. +func (s *testStack) open(t testing.TB) { + t.Helper() + s.writers = extension.NewWriters(s.store, s.registry, extension.Admission{}, session.OpenOptions{Takeover: true}) + rt, err := runmod.NewRuntime(runmod.Config{Writers: s.writers, Registry: s.registry, Store: s.store, Companion: nopCompanion{}, Now: s.now}) + if err != nil { + t.Fatal(err) + } + s.runtime = rt +} + +// createRun appends the Start group of one Run with a seed input (RUN-NEW-1). +func (s *testStack) createRun(t testing.TB, runID RunID, inputs ...AgentInput) { + t.Helper() + newRun, err := BuildNewRun(runID, "") + if err != nil { + t.Fatal(err) + } + facts, err := ProtocolV1().BuildCreateGroup(newRun, inputs) + if err != nil { + t.Fatal(err) + } + group := &extension.SemanticGroup{CommitID: session.CommitID("create/" + string(runID))} + for _, f := range facts { + group.Events = append(group.Events, extension.TypedEvent{Type: runmod.EventType(f), Value: runmod.Event{RunID: runID, Fact: f}}) + } + w, err := s.writers.Writer(context.Background(), testSession) + if err != nil { + t.Fatal(err) + } + res, err := w.Commit(context.Background(), func(extension.View) (*extension.SemanticGroup, error) { return group, nil }) + if err != nil { + t.Fatal(err) + } + if res.Outcome != extension.CommitApplied { + t.Fatalf("create run: %s %s", res.Outcome, res.Detail) + } +} + +// newTestRuntime is a Runtime holding "run-1" seeded with one input. +func newTestRuntime(t testing.TB) Runtime { + t.Helper() + stack := newTestStack(t, nil) + stack.createRun(t, "run-1", AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}) + return stack.runtime +} + +func loopRuntime(t *testing.T) Runtime { + t.Helper() + return newTestRuntime(t) +} + +// recordFacts returns every committed fact of runID in stream order. +func recordFacts(t testing.TB, rt Runtime, runID RunID) []Fact { + t.Helper() + record, err := rt.Record(context.Background(), testSession, runID) + if err != nil { + t.Fatal(err) + } + return record.Facts +} + +func loadState(t testing.TB, rt Runtime, runID RunID) RuntimeSnapshot { + t.Helper() + snap, err := rt.Load(context.Background(), testSession, runID) + if err != nil { + t.Fatal(err) + } + return snap +} diff --git a/agent/run/loop/loop.go b/agent/run/loop/loop.go new file mode 100644 index 0000000..ccb79d3 --- /dev/null +++ b/agent/run/loop/loop.go @@ -0,0 +1,195 @@ +package loop + +import ( + "context" + "errors" + "fmt" + "sync" + + run "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" +) + +// Loop is the in-process interpreter of one Run (RUN-LOP-2). It holds no +// authoritative state; every iteration starts from Runtime.Load. +type Loop struct { + Models ModelCatalog + Tools ToolCatalog + Planner RequestPlanner + Execution ExecutionPolicy + Streaming bool + + runsMu sync.Mutex + runs map[run.RunID]struct{} + eventsMu sync.Mutex +} + +// New validates and normalizes the execution policy (RUN-LOP-1). +func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy ExecutionPolicy, streaming bool) (*Loop, error) { + if models == nil { + return nil, errors.New("agent: loop: nil model catalog") + } + if tools == nil { + return nil, errors.New("agent: loop: nil tool catalog") + } + if planner == nil { + return nil, errors.New("agent: loop: nil request planner") + } + if policy.ToolExecution != "" && policy.ToolExecution != ToolExecutionParallel && policy.ToolExecution != ToolExecutionSequential { + return nil, fmt.Errorf("agent: loop: unknown ToolExecution mode %q", policy.ToolExecution) + } + if policy.MaxParallel < 0 { + return nil, errors.New("agent: loop: negative MaxParallel") + } + return &Loop{Models: models, Tools: tools, Planner: planner, Execution: policy, Streaming: streaming, + runs: make(map[run.RunID]struct{})}, nil +} + +func (l *Loop) toolScheduling() run.ToolScheduling { + mode := run.ToolScheduleMode(l.Execution.ToolExecution) + if mode == "" { + mode = run.ToolScheduleParallel + } + return run.ToolScheduling{Mode: mode, MaxParallel: l.Execution.MaxParallel} +} + +func (l *Loop) acquireRun(runID run.RunID) error { + l.runsMu.Lock() + defer l.runsMu.Unlock() + if _, ok := l.runs[runID]; ok { + return ErrRunAlreadyRunning + } + l.runs[runID] = struct{}{} + return nil +} + +func (l *Loop) releaseRun(runID run.RunID) { + l.runsMu.Lock() + delete(l.runs, runID) + l.runsMu.Unlock() +} + +// Run drives the Run until it finishes, has no executable effect, or the +// context is cancelled (RUN-LOP-2). The caller context remains active for +// reads and normal control commits. Accepted effect settlements use a +// detached control context so worker cancellation cannot discard their outcome. +func (l *Loop) Run(ctx context.Context, rt run.Runtime, sid session.SessionID, runID run.RunID, events EventSink) (LoopResult, error) { + if ctx == nil { + return LoopResult{}, errors.New("agent: loop: nil context") + } + if rt == nil { + return LoopResult{}, errors.New("agent: loop: nil runtime") + } + if sid == "" || runID == "" { + return LoopResult{}, errors.New("agent: loop: empty SessionID or RunID") + } + if err := l.acquireRun(runID); err != nil { + return LoopResult{}, err + } + defer l.releaseRun(runID) + if events != nil { + events = &serializedEventSink{sink: events, mu: &l.eventsMu} + } + runtime := boundRuntime{rt: rt, sid: sid} + // finish is the single exit for a terminal Run, whether the terminal state + // was read by Load or returned by the settlement that produced it. + finish := func(result *run.RunResult) LoopResult { + if events != nil { + _ = events.Emit(ctx, Event{Session: sid, RunID: runID, Kind: EventRunFinished, Durability: EventCommitted}) + } + return LoopResult{Disposition: LoopFinished, Result: result} + } + + for { + if err := ctx.Err(); err != nil { + // Workers started by this Loop have already been settled by the + // branches below before we reach this check. + return LoopResult{}, err + } + snapshot, err := runtime.Load(ctx, runID) + if err != nil { + return LoopResult{}, err + } + if snapshot.State.RunID != runID { + return LoopResult{}, fmt.Errorf("agent: loop: runtime returned RunID %q for %q", snapshot.State.RunID, runID) + } + if snapshot.State.Status.Terminal() { + return finish(snapshot.State.Result), nil + } + + effect, err := run.Next(snapshot.State) + if err != nil { + return LoopResult{}, err + } + + switch eff := effect.(type) { + case run.NeedModelRequest: + if err := l.planAndPrepare(ctx, runtime, events, &snapshot, eff.Hint); err != nil { + return LoopResult{}, err + } + case run.WithdrawPrepared: + // Inputs arrived after this step was frozen: discard the unsent + // request and replan with them (RUN-LOP-8). A retriable rejection + // means another actor moved the Run; the reload decides. + proto, err := snapshot.Protocol() + if err != nil { + return LoopResult{}, err + } + res, err := l.commit(ctx, runtime, runID, run.DeriveWithdrawCommandID(runID, eff.StepID), snapshot.Position, + run.WithdrawPreparedStep{StepID: eff.StepID}, proto) + if err != nil && !retriable(err) { + return LoopResult{}, err + } + if err == nil { + l.emitCommitted(ctx, events, sid, runID, res.Events) + } + case run.StartModelCall: + finished, err := l.runModelStep(ctx, runtime, events, &snapshot, eff.StepID) + if err != nil { + return LoopResult{}, err + } + if finished != nil { + return finish(finished), nil + } + case run.StartToolCalls: + if err := l.runToolCalls(ctx, runtime, events, &snapshot, eff); err != nil { + return LoopResult{}, err + } + case run.Idle: + recovery := run.NeedsRecovery(snapshot.State) + reason := WaitReason("") + if recovery { + reason = ExecutionRecovery + } + return LoopResult{Disposition: LoopWaiting, Reason: reason, ExecutionRecovery: recovery}, nil + default: + return LoopResult{}, fmt.Errorf("agent: loop: unknown effect %T", effect) + } + } +} + +// commit builds the envelope via the sanctioned constructor and submits it. +// A non-sentinel commit failure is replayed once with the same CommandID and +// digest (RUN-LOP-5): if the first attempt actually committed and only the +// response was lost, the replay returns AlreadyApplied instead of +// re-executing an expensive step. Ownership loss is never retried. +func (l *Loop) commit(ctx context.Context, runtime boundRuntime, runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand, proto run.Protocol) (run.CommitResult, error) { + if proto.Version() == 0 { + return run.CommitResult{}, errors.New("agent: loop: uninitialized protocol") + } + env, err := proto.BuildEnvelope(runtime.sid, runID, id, cmd) + if err != nil { + return run.CommitResult{}, err + } + req := run.CommitRequest{Base: base, Command: env} + res, err := runtime.Commit(ctx, req) + if err != nil && !retriable(err) && !ownershipLost(err) { + res, err = runtime.Commit(ctx, req) + } + return res, err +} + +// retriable reports the commit errors that mean "reload and rederive". +func retriable(err error) bool { + return errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrCommandConflict) +} diff --git a/agent/run/loop/loop_test.go b/agent/run/loop/loop_test.go new file mode 100644 index 0000000..189587c --- /dev/null +++ b/agent/run/loop/loop_test.go @@ -0,0 +1,509 @@ +package loop + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + + . "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + + "github.com/felinics/twilight/sdk" +) + +// --- fakes --- + +type fakeInvoker struct { + results []sdk.ModelResult + calls atomic.Int32 +} + +func (f *fakeInvoker) Generate(ctx context.Context, req sdk.Request) (sdk.ModelResult, error) { + if err := ctx.Err(); err != nil { + return sdk.ModelResult{}, err + } + n := int(f.calls.Add(1)) - 1 + if n >= len(f.results) { + return sdk.ModelResult{}, errors.New("fake: no scripted result") + } + return f.results[n], nil +} + +type blockingInvoker struct { + started chan struct{} + release chan struct{} +} + +func (b *blockingInvoker) Generate(context.Context, sdk.Request) (sdk.ModelResult, error) { + close(b.started) + <-b.release + return textResult("done"), nil +} + +type fakeCatalog struct{ invoker ModelInvoker } + +func (c fakeCatalog) ResolveModel(ModelRef) (ModelInvoker, error) { return c.invoker, nil } + +type fakeTool struct { + ref ToolRef + def sdk.ToolDefinition + policy ResponsePolicy + execute func(context.Context, ToolExecutionRequest) ToolExecutionOutcome + valErr error +} + +func (f *fakeTool) Ref() ToolRef { return f.ref } +func (f *fakeTool) Definition() sdk.ToolDefinition { return f.def } +func (f *fakeTool) ResponsePolicy() ResponsePolicy { return f.policy } +func (f *fakeTool) ValidateArguments(CanonicalJSON) error { return f.valErr } +func (f *fakeTool) Execute(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { + return f.execute(ctx, req) +} + +type fakeToolCatalog struct{ tools map[ToolRef]ExecutableTool } + +func (c fakeToolCatalog) ResolveTool(ref ToolRef) (ExecutableTool, error) { + t, ok := c.tools[ref] + if !ok { + return nil, fmt.Errorf("unknown tool %q", ref) + } + return t, nil +} + +// staticPlanner freezes one request per Plan call; tools mirror the catalog. +type staticPlanner struct { + model ModelRef + specs []ToolSpec +} + +func (p staticPlanner) Plan(_ context.Context, hint PlanningHint) (RequestPlan, error) { + model := p.model + if model == "" { + model = testModel + } + req := sdk.Request{Model: string(model), Messages: []sdk.Message{sdk.UserMessage("go")}} + for _, s := range p.specs { + req.Tools = append(req.Tools, toolDef(s.Name)) + } + ids := make([]InputID, len(hint.Inputs)) + for i, in := range hint.Inputs { + ids[i] = in.ID + } + return RequestPlan{Model: model, Request: req, InputIDs: ids, Tools: p.specs}, nil +} + +// toolDef is the provider definition every test tool shares; ToolSpec keeps +// only its digest, so tests rebuild the body from the name. +func toolDef(name string) sdk.ToolDefinition { + return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} +} + +func toolSpec(t *testing.T, name string, policy ResponsePolicy) ToolSpec { + t.Helper() + frozen, err := FreezeToolDefinition(toolDef(name)) + if err != nil { + t.Fatal(err) + } + d, err := ProtocolV1().DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) + } + return ToolSpec{Ref: ToolRef(name), Name: name, DefinitionDigest: d, Policy: policy} +} + +func textResult(text string) sdk.ModelResult { + return sdk.ModelResult{Text: text, FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}} +} + +func toolCallResult(ids ...string) sdk.ModelResult { + r := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 2}} + for _, id := range ids { + r.ToolCalls = append(r.ToolCalls, sdk.ToolCall{ToolCallID: id, ToolName: "echo", Input: `{"x":1}`}) + } + return r +} + +// --- tests --- + +func TestNewLeavesEmptyToolExecution(t *testing.T) { + loop, err := New(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + if loop.Execution.ToolExecution != "" { + t.Fatalf("ToolExecution = %q, want empty", loop.Execution.ToolExecution) + } +} + +func TestLoopRejectsConcurrentRunForSameID(t *testing.T) { + rt := loopRuntime(t) + invoker := &blockingInvoker{started: make(chan struct{}), release: make(chan struct{})} + loop, err := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + _, runErr := loop.Run(context.Background(), rt, testSession, "run-1", nil) + done <- runErr + }() + <-invoker.started + if _, err := loop.Run(context.Background(), rt, testSession, "run-1", nil); !errors.Is(err, ErrRunAlreadyRunning) { + t.Fatalf("concurrent Run error = %v, want ErrRunAlreadyRunning", err) + } + close(invoker.release) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +type errCatalog struct{ err error } + +func (c errCatalog) ResolveModel(ModelRef) (ModelInvoker, error) { return nil, c.err } + +func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { + rt := loopRuntime(t) + missing := errors.New("missing provider") + broken, err := New(errCatalog{missing}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + _, err = broken.Run(context.Background(), rt, testSession, "run-1", nil) + if !errors.Is(err, missing) { + t.Fatalf("err = %v, want %v", err, missing) + } + snap, loadErr := rt.Load(context.Background(), testSession, "run-1") + if loadErr != nil { + t.Fatal(loadErr) + } + if snap.State.Status != RunActive { + t.Fatalf("status = %v", snap.State.Status) + } + ms, ok := snap.State.Current.(ModelStep) + if !ok || ms.Status != ModelPrepared { + t.Fatalf("current = %+v", snap.State.Current) + } + if snap.State.ModelSteps != 1 { + t.Fatalf("ModelSteps = %d, want 1", snap.State.ModelSteps) + } + + invoker := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} + ready, err := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + res, err := ready.Run(context.Background(), rt, testSession, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Result == nil || res.Result.Status != RunCompleted || invoker.calls.Load() != 1 { + t.Fatalf("res = %+v, model calls = %d", res, invoker.calls.Load()) + } + final, err := rt.Load(context.Background(), testSession, "run-1") + if err != nil { + t.Fatal(err) + } + if final.State.ModelSteps != 1 { + t.Fatalf("ModelSteps = %d after resume, want 1", final.State.ModelSteps) + } +} + +func TestLoopParallelBounded(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + var concurrent, peak atomic.Int32 + gate := make(chan struct{}) + started := make(chan struct{}, 3) + echo := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + cur := concurrent.Add(1) + for { + p := peak.Load() + if cur <= p || peak.CompareAndSwap(p, cur) { + break + } + } + started <- struct{}{} + <-gate // hold every worker until released so concurrency is real + concurrent.Add(-1) + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"ok"`)}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1", "c2", "c3"), textResult("done")}} + rt := loopRuntime(t) + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{MaxParallel: 2}, false) + + done := make(chan struct{}) + var res LoopResult + var runErr error + go func() { + res, runErr = loop.Run(context.Background(), rt, testSession, "run-1", nil) + close(done) + }() + + // Exactly MaxParallel workers must be running before the gate opens. + <-started + <-started + if concurrent.Load() != 2 { + t.Fatalf("concurrent = %d before gate, want 2", concurrent.Load()) + } + close(gate) + <-done + if runErr != nil { + t.Fatal(runErr) + } + if res.Result.Status != RunCompleted { + t.Fatalf("res = %+v", res) + } + if peak.Load() != 2 { + t.Fatalf("peak concurrency = %d, want exactly 2 (bounded and actually parallel)", peak.Load()) + } +} + +type staleCommitRuntime struct{ Runtime } + +func (staleCommitRuntime) Commit(context.Context, session.SessionID, CommitRequest) (CommitResult, error) { + return CommitResult{}, ErrStaleRuntime +} + +// A stale start rejection is not an error: the Loop returns and the next +// Load decides what the other actor left behind. +func TestToolStartStaleIsNotAnError(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + args := cj(`{}`) + callID := DeriveCallID("model-1", 0) + bindingDigest, err := DigestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, args) + if err != nil { + t.Fatal(err) + } + echo := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: args}} + }} + loop, err := New(fakeCatalog{&fakeInvoker{}}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + stepID := StepID("step-1") + snapshot := &RuntimeSnapshot{State: MachineState{ + RunID: "run-1", Status: RunActive, + Current: ToolStep{ + RefValue: StepRef{RunID: "run-1", ID: stepID, Digest: Digest("sha256:step")}, + Source: "model-1", + Calls: []ToolCallState{{ + CallID: callID, ProviderCallID: "c1", ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bindingDigest, Arguments: args, Policy: DirectExecution, Status: ToolPending, + }}, + }, + }, Position: 1, SchemaVersion: SchemaVersion1} + + if err := loop.runToolCalls(context.Background(), boundRuntime{rt: staleCommitRuntime{}, sid: testSession}, nil, snapshot, + StartToolCalls{StepID: stepID, CallIDs: []CallID{callID}}); err != nil { + t.Fatal(err) + } +} + +type responseLossRuntime struct { + Runtime + mu sync.Mutex + count map[CommandID]int + loseModelStart bool +} + +func newResponseLossRuntime(t *testing.T) *responseLossRuntime { + t.Helper() + return &responseLossRuntime{Runtime: loopRuntime(t), count: make(map[CommandID]int)} +} + +func (r *responseLossRuntime) Commit(ctx context.Context, sid session.SessionID, req CommitRequest) (CommitResult, error) { + result, err := r.Runtime.Commit(ctx, sid, req) + if err != nil { + return result, err + } + r.mu.Lock() + r.count[req.Command.ID]++ + count := r.count[req.Command.ID] + _, lose := req.Command.Command.(StartModelExecution) + lose = lose && r.loseModelStart + if _, ok := req.Command.Command.(SubmitToolResult); ok { + lose = true + } + r.mu.Unlock() + if lose && count <= 2 { + return CommitResult{}, errors.New("test: response lost") + } + return result, nil +} + +func TestLoopReplaysStartAfterTwoLostResponses(t *testing.T) { + rt := newResponseLossRuntime(t) + rt.loseModelStart = true + invoker := &fakeInvoker{results: []sdk.ModelResult{textResult("recovered")}} + loop, err := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + if _, err := loop.Run(context.Background(), rt, testSession, "run-1", nil); err == nil { + t.Fatal("first run unexpectedly completed after lost start responses") + } + snapshot, err := rt.Load(context.Background(), testSession, "run-1") + if err != nil { + t.Fatal(err) + } + // The first Loop reaches the start barrier; both start responses are lost, + // so the authority remains Executing while the worker's claim is gone with + // the aborted attempt. A second Run has nothing to execute (RUN-LOP-4). + if current, ok := snapshot.State.Current.(ModelStep); !ok || current.Status != ModelExecuting { + t.Fatalf("current = %#v, want Executing ModelStep", snapshot.State.Current) + } + res, err := loop.Run(context.Background(), rt, testSession, "run-1", nil) + if err != nil || res.Disposition != LoopWaiting || !res.ExecutionRecovery { + t.Fatalf("run with an orphaned Executing step = %+v %v, want waiting for recovery", res, err) + } + // The owner's takeover disposition returns the step to Prepared; the next + // Run reissues the same frozen request exactly once (RUN-CMT-7). + if n, err := rt.RecoverInterrupted(context.Background(), testSession); err != nil || n != 1 { + t.Fatalf("RecoverInterrupted = %d %v", n, err) + } + rt.loseModelStart = false // the transport is healthy again + if _, err := loop.Run(context.Background(), rt, testSession, "run-1", nil); err != nil { + t.Fatal(err) + } + if invoker.calls.Load() != 1 { + t.Fatalf("model calls = %d, want 1", invoker.calls.Load()) + } +} + +func TestLoopReplaysSettlementWithoutRepeatingTool(t *testing.T) { + rt := newResponseLossRuntime(t) + spec := toolSpec(t, "echo", DirectExecution) + var executions atomic.Int32 + echo := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, + execute: func(_ context.Context, req ToolExecutionRequest) ToolExecutionOutcome { + executions.Add(1) + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} + loop, err := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + if _, err := loop.Run(context.Background(), rt, testSession, "run-1", nil); err == nil { + t.Fatal("first run unexpectedly completed after lost settlement responses") + } + if executions.Load() != 1 { + t.Fatalf("tool executions = %d, want 1", executions.Load()) + } + res, err := loop.Run(context.Background(), rt, testSession, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Disposition != LoopFinished || res.Result == nil || res.Result.Status != RunCompleted { + t.Fatalf("result = %+v", res) + } + if executions.Load() != 1 { + t.Fatalf("tool executions after replay = %d, want 1", executions.Load()) + } +} + +func TestLoopMalformedModelResultDispositionFailsRun(t *testing.T) { + rt := loopRuntime(t) + // Non-JSON argument text is tolerated (bound raw, fails later as + // invalid_arguments), but invalid UTF-8 cannot be frozen at all: the + // result is structurally malformed and goes through RejectModelResult. + bad := sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + ToolCalls: []sdk.ToolCall{{ToolCallID: "c1", ToolName: "echo", Input: "\xff\xfe"}}, + Usage: sdk.Usage{TotalTokens: 1}, + } + invoker := &fakeInvoker{results: []sdk.ModelResult{bad, bad, bad}} + loop, err := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{ + OnMalformedModelResult: func(step ModelStep, _ StepFailure) ModelRejectDisposition { + if step.Rejects < 2 { + return ModelRejectRetry + } + return ModelRejectFailRun + }, + }, false) + if err != nil { + t.Fatal(err) + } + res, err := loop.Run(context.Background(), rt, testSession, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if got := invoker.calls.Load(); got != 3 { + t.Fatalf("model calls = %d, want 3", got) + } + if res.Result == nil || res.Result.Status != RunFailed || res.Result.Reason != ReasonMalformedModel { + t.Fatalf("loop result = %+v", res) + } +} + +type panicPlanner struct{} + +func (panicPlanner) Plan(context.Context, PlanningHint) (RequestPlan, error) { + panic("planner should not be called") +} + +// cancellingInvoker cancels the outer ctx from inside Generate, simulating a +// shutdown arriving mid-execution. +type cancellingInvoker struct{ cancel context.CancelFunc } + +func (c *cancellingInvoker) Generate(ctx context.Context, _ sdk.Request) (sdk.ModelResult, error) { + c.cancel() + <-ctx.Done() + return sdk.ModelResult{}, ctx.Err() +} + +func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + invoker := &cancellingInvoker{cancel: cancel} + rt := loopRuntime(t) + loop, _ := New(fakeCatalog{invoker}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + + _, err := loop.Run(ctx, rt, testSession, "run-1", nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + // The model step must be back to Prepared via RecoverModelExecution: + // same frozen request, run still active, ModelSteps not recounted. + snap, _ := rt.Load(context.Background(), testSession, "run-1") + ms, ok := snap.State.Current.(ModelStep) + if !ok || ms.Status != ModelPrepared { + t.Fatalf("current = %#v, want Prepared ModelStep", snap.State.Current) + } + if snap.State.ModelSteps != 1 { + t.Fatalf("ModelSteps = %d", snap.State.ModelSteps) + } + recovered := false + for _, e := range recordFacts(t, rt, "run-1") { + if _, ok := e.(ModelStepRecovered); ok { + recovered = true + } + } + if !recovered { + t.Fatal("no ModelStepRecovered fact committed") + } + + // A fresh Loop resumes the SAME frozen step without a new Prepare. + invoker2 := &fakeInvoker{results: []sdk.ModelResult{textResult("resumed")}} + loop2, _ := New(fakeCatalog{invoker2}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + res, err := loop2.Run(context.Background(), rt, testSession, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Result == nil || res.Result.Status != RunCompleted || invoker2.calls.Load() != 1 { + t.Fatalf("res = %+v, model calls = %d", res, invoker2.calls.Load()) + } + final, _ := rt.Load(context.Background(), testSession, "run-1") + if final.State.ModelSteps != 1 { + t.Fatalf("ModelSteps = %d after resume, want 1 (same frozen step)", final.State.ModelSteps) + } +} diff --git a/agent/run/loop/model.go b/agent/run/loop/model.go new file mode 100644 index 0000000..7dc0b81 --- /dev/null +++ b/agent/run/loop/model.go @@ -0,0 +1,289 @@ +package loop + +import ( + "context" + "errors" + "fmt" + + run "github.com/felinics/twilight/agent/run" + + "github.com/felinics/twilight/sdk" +) + +func (l *Loop) planAndPrepare(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot, hint run.PlanningHint) error { + hint.Session = runtime.sid + plan, err := l.Planner.Plan(ctx, hint) + if err != nil { + return err + } + frozenRequest, err := run.FreezeModelRequest(plan.Request) + if err != nil { + return err + } + model := plan.Model + if model == "" { + model = run.ModelRef(frozenRequest.Model) + } + if model == "" { + return fmt.Errorf("agent: loop: empty model") + } + if run.ModelRef(frozenRequest.Model) != model { + return fmt.Errorf("agent: loop: request model %q does not match plan model %q", frozenRequest.Model, model) + } + proto, err := snapshot.Protocol() + if err != nil { + return err + } + requestDigest, err := proto.DigestRequest(frozenRequest) + if err != nil { + return err + } + toolsDigest, err := proto.DigestToolSpecs(plan.Tools) + if err != nil { + return err + } + binding, err := proto.DigestModelStepBinding(model, requestDigest, toolsDigest) + if err != nil { + return err + } + cmdID := run.DeriveModelRequestCommandID(snapshot.State.RunID, snapshot.Position) + stepID := run.DeriveModelStepID(snapshot.State.RunID, cmdID, binding) + res, err := l.commit(ctx, runtime, snapshot.State.RunID, cmdID, snapshot.Position, run.PrepareModelRequest{ + StepID: stepID, + Model: model, + Request: frozenRequest, + RequestDigest: requestDigest, + InputIDs: plan.InputIDs, + PlanningToken: plan.PlanningToken, + Tools: plan.Tools, + ToolsDigest: toolsDigest, + }, proto) + if err == nil { + // ModelStepPrepared carries the frozen request — the most informative + // fact of the run; observers must see it like every other accepted + // transition. + l.emitCommitted(ctx, events, runtime.sid, snapshot.State.RunID, res.Events) + return nil + } + if !retriable(err) { + return err + } + // A retriable rejection with no authority progress means the rejection + // was about THIS plan's content (InputIDs, digests), not concurrency: + // retrying the same planner at the same revision would spin forever. + after, loadErr := runtime.Load(ctx, snapshot.State.RunID) + if loadErr != nil { + return loadErr + } + if after.Position == snapshot.Position { + return fmt.Errorf("agent: loop: prepare rejected without authority progress: %w", err) + } + return nil // another actor advanced the run; reload decides the next action +} + +// --- StartModelCall --- + +// runModelStep owns one model execution attempt. It returns the terminal +// RunResult when its settlement ended the Run (RUN 7: no reload after a +// terminal settlement). +func (l *Loop) runModelStep(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot, stepID run.StepID) (*run.RunResult, error) { + runID := snapshot.State.RunID + proto, err := snapshot.Protocol() + if err != nil { + return nil, err + } + a := newAttempt(runID, stepID, "") + start, err := l.commit(ctx, runtime, runID, a.startID(), snapshot.Position, run.StartModelExecution{StepID: stepID, Claim: a.claim}, proto) + if err != nil { + if retriable(err) { + return nil, nil // another actor moved the step; reload decides + } + return nil, err + } + l.emitCommitted(ctx, events, runtime.sid, runID, start.Events) + + modelStep, ok := start.Snapshot.State.Current.(run.ModelStep) + if !ok || modelStep.RefValue.ID != stepID || modelStep.Status != run.ModelExecuting { + // The start (or its one-shot replay) landed but the step is no longer + // Executing: something settled it meanwhile. Reload decides. + if start.Status == run.CommitAlreadyApplied { + return nil, nil + } + return nil, fmt.Errorf("agent: loop: started step %q is not current", stepID) + } + + var completion run.AgentCommand + var catalogErr error + invoker, resolveErr := l.Models.ResolveModel(modelStep.Model) + switch { + case resolveErr != nil: + catalogErr = resolveErr + completion = run.RecoverModelExecution{StepID: stepID, Claim: a.claim} + case invoker == nil: + catalogErr = errors.New("model catalog returned a nil invoker") + completion = run.RecoverModelExecution{StepID: stepID, Claim: a.claim} + default: + // Model workers derive from the outer ctx: cancelling a model call is + // safe, the frozen request retries after recovery (RUN-LOP-3). The body + // is fetched by digest; a missing body cannot be retried by this Loop. + frozenRequest, fetchErr := runtime.FrozenRequest(ctx, modelStep.RequestDigest) + var sdkRequest sdk.Request + if fetchErr == nil { + sdkRequest, fetchErr = frozenRequest.SDK() + } + if fetchErr != nil { + if errors.Is(fetchErr, run.ErrFrozenValueMissing) { + // Release ownership so recovery or a fresh plan can proceed; + // surface the condition to the host. + if _, err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, run.RecoverModelExecution{StepID: stepID, Claim: a.claim}, proto); err != nil { + return nil, err + } + return nil, fetchErr + } + failure := run.StepFailure{Class: run.FailureMalformedModel, Message: fetchErr.Error()} + completion = run.RejectModelResult{StepID: stepID, Failure: failure, Disposition: l.modelRejectDisposition(modelStep, failure)} + } else { + result, invokeErr := l.invokeModel(ctx, invoker, &sdkRequest, runID, stepID, events) + switch { + case invokeErr != nil && ctx.Err() != nil: + completion = run.RecoverModelExecution{StepID: stepID, Claim: a.claim} + case invokeErr != nil: + completion = run.SubmitModelFailure{StepID: stepID, Failure: run.StepFailure{Class: run.FailureProvider, Message: invokeErr.Error()}} + default: + bindings, bindErr := l.bindToolCalls(&result, &modelStep) + if bindErr != nil { + completion = run.RejectModelResult{StepID: stepID, Usage: run.UsageFromSDK(result.Usage), + Failure: run.StepFailure{Class: run.FailureMalformedModel, Message: bindErr.Error()}, + Disposition: l.modelRejectDisposition(modelStep, run.StepFailure{Class: run.FailureMalformedModel, Message: bindErr.Error()})} + } else if frozenResult, freezeErr := run.FreezeModelResult(result); freezeErr != nil { + completion = run.RejectModelResult{StepID: stepID, Usage: run.UsageFromSDK(result.Usage), + Failure: run.StepFailure{Class: run.FailureMalformedModel, Message: freezeErr.Error()}, + Disposition: l.modelRejectDisposition(modelStep, run.StepFailure{Class: run.FailureMalformedModel, Message: freezeErr.Error()})} + } else { + completion = run.SubmitModelResult{StepID: stepID, Result: frozenResult, Calls: bindings, Scheduling: l.toolScheduling()} + } + } + } + } + + finished, err := l.settle(ctx, runtime, events, a, start.Snapshot.Position, completion, proto) + if err != nil { + return nil, err + } + if catalogErr != nil { + return nil, fmt.Errorf("agent: loop: model catalog: %w", catalogErr) + } + return finished, nil +} + +func (l *Loop) modelRejectDisposition(step run.ModelStep, failure run.StepFailure) run.ModelRejectDisposition { + if l.Execution.OnMalformedModelResult != nil { + disposition := l.Execution.OnMalformedModelResult(step, failure) + if disposition == run.ModelRejectRetry || disposition == run.ModelRejectFailRun { + return disposition + } + // Do not leave a model step Executing because a host callback returned + // an unknown enum value; a malformed result must still settle. + return run.ModelRejectFailRun + } + // A malformed result is never retried implicitly. Hosts that want a retry + // must provide the handler and return ModelRejectRetry explicitly. + return run.ModelRejectFailRun +} + +func (l *Loop) invokeModel(ctx context.Context, invoker ModelInvoker, req *sdk.Request, runID run.RunID, step run.StepID, events EventSink) (sdk.ModelResult, error) { + if l.Streaming { + if streamer, ok := invoker.(StreamingModelInvoker); ok { + stream, err := streamer.Stream(ctx, *req) + if err != nil { + return sdk.ModelResult{}, err + } + // The range has an explicit ctx escape: a stream that stops + // sending without closing Parts must not block cancellation and + // the recovery path behind it. + var sequence uint64 + emitDelta := func(kind EventKind, payload any) { + if events == nil { + return + } + sequence++ + _ = events.Emit(ctx, Event{RunID: runID, StepID: step, + Sequence: sequence, Kind: kind, Durability: EventProvisional, + Payload: mustJSON(payload)}) + } + consume: + for { + select { + case part, open := <-stream.Parts: + if !open { + break consume + } + if events == nil { + continue + } + switch p := part.(type) { + case *sdk.TextDeltaPart: + emitDelta(EventModelTextDelta, p.Text) + case *sdk.ReasoningDeltaPart: + emitDelta(EventModelReasoningDelta, p.Text) + } + case <-ctx.Done(): + return sdk.ModelResult{}, ctx.Err() + } + } + result, err := stream.Result() + if err != nil { + return sdk.ModelResult{}, err + } + if result == nil { + return sdk.ModelResult{}, errors.New("agent: loop: stream returned no result") + } + return *result, nil + } + } + return invoker.Generate(ctx, *req) +} + +// bindToolCalls validates tool-call IDs/order/shape and produces bindings +// from the frozen ToolSpecs (RUN-MCH-2). It never calls ExecutableTool. +func (l *Loop) bindToolCalls(result *sdk.ModelResult, step *run.ModelStep) ([]run.ToolCallBinding, error) { + if len(result.ToolCalls) == 0 { + return nil, nil + } + specByName := make(map[string]run.ToolSpec, len(step.Tools)) + for _, s := range step.Tools { + specByName[s.Name] = s + } + bindings := make([]run.ToolCallBinding, len(result.ToolCalls)) + for i, tc := range result.ToolCalls { + args, err := run.FreezeToolCallInput(tc.Input) + if err != nil { + return nil, fmt.Errorf("tool call %d (%q) input: %w", i, tc.ToolCallID, err) + } + // The Run's CallID derives from the step and position; the provider's + // id is carried for the round trip only, so a provider that repeats or + // omits ids cannot break identity here. + b := run.ToolCallBinding{ + CallID: run.DeriveCallID(step.RefValue.ID, i), + ProviderCallID: tc.ToolCallID, + ToolRef: run.ToolRef(tc.ToolName), + Arguments: args, + Policy: run.DirectExecution, + } + if spec, known := specByName[tc.ToolName]; known { + // The binding's ToolRef is the frozen spec's Ref — the catalog + // key — not the model-facing definition name; the two may differ + // (aliased tools). + b.ToolRef = spec.Ref + b.DefinitionDigest = spec.DefinitionDigest + b.Policy = spec.Policy + } + bd, err := run.DigestToolCallBinding(b.CallID, b.DefinitionDigest, b.Policy, b.Arguments) + if err != nil { + return nil, err + } + b.BindingDigest = bd + bindings[i] = b + } + return bindings, nil +} diff --git a/agent/run/loop/regression_test.go b/agent/run/loop/regression_test.go new file mode 100644 index 0000000..b42d1bf --- /dev/null +++ b/agent/run/loop/regression_test.go @@ -0,0 +1,136 @@ +package loop + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync/atomic" + "testing" + + . "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/sdk" +) + +func TestRegressionToolPanicBecomesUnknown(t *testing.T) { + spec := toolSpec(t, "echo", DirectExecution) + echo := &fakeTool{ref: "echo", def: toolDef(spec.Name), policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + panic("nil map write") + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{toolCallResult("c1"), textResult("done")}} + rt := loopRuntime(t) + interpreter, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"echo": echo}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + res, err := interpreter.Run(context.Background(), rt, testSession, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Result.Status != RunCompleted { + t.Fatalf("res = %+v", res.Result) + } + found := false + for _, e := range recordFacts(t, rt, "run-1") { + failed, ok := e.(ToolCallFailed) + if !ok || failed.Outcome != ToolOutcomeUnknown { + continue + } + if strings.Contains(failed.Failure.Message, "panic") { + found = true + break + } + } + if !found { + t.Fatal("missing ToolCallFailed Unknown with panic") + } +} + +func TestRegressionRunFinishedEmitted(t *testing.T) { + rt := loopRuntime(t) + var kinds []EventKind + sink := sinkFunc(func(_ context.Context, e Event) error { + kinds = append(kinds, e.Kind) + return nil + }) + interpreter, _ := New(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("done")}}}, + fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, false) + if _, err := interpreter.Run(context.Background(), rt, testSession, "run-1", sink); err != nil { + t.Fatal(err) + } + for _, k := range kinds { + if k == EventRunFinished { + return + } + } + t.Fatalf("EventRunFinished never emitted; kinds = %v", kinds) +} + +func TestRegressionAliasedToolRefExecutes(t *testing.T) { + def := sdk.ToolDefinition{Name: "read", Parameters: json.RawMessage(`{"type":"object"}`)} + frozenDef, err := FreezeToolDefinition(def) + if err != nil { + t.Fatal(err) + } + d, err := ProtocolV1().DigestToolDefinition(frozenDef) + if err != nil { + t.Fatal(err) + } + spec := ToolSpec{Ref: "fs.read", Name: "read", DefinitionDigest: d, Policy: DirectExecution} + executed := atomic.Bool{} + tool := &fakeTool{ref: "fs.read", def: def, policy: DirectExecution, + execute: func(context.Context, ToolExecutionRequest) ToolExecutionOutcome { + executed.Store(true) + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: cj(`"ok"`)}} + }} + invoker := &fakeInvoker{results: []sdk.ModelResult{ + func() sdk.ModelResult { + r := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls} + r.ToolCalls = []sdk.ToolCall{{ToolCallID: "c1", ToolName: "read", Input: `{}`}} + return r + }(), + textResult("done"), + }} + rt := loopRuntime(t) + interpreter, _ := New(fakeCatalog{invoker}, fakeToolCatalog{map[ToolRef]ExecutableTool{"fs.read": tool}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + + res, err := interpreter.Run(context.Background(), rt, testSession, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if !executed.Load() { + t.Fatal("aliased tool never executed") + } + if res.Result.Status != RunCompleted { + t.Fatalf("res = %+v", res.Result) + } +} + +func TestRegressionStreamNilResult(t *testing.T) { + rt := loopRuntime(t) + interpreter, _ := New(fakeCatalog{nilResultStreamer{}}, fakeToolCatalog{}, staticPlanner{}, ExecutionPolicy{}, true) + res, err := interpreter.Run(context.Background(), rt, testSession, "run-1", nil) + if err != nil { + t.Fatal(err) + } + if res.Result.Status != RunFailed { + t.Fatalf("res = %+v", res.Result) + } +} + +type sinkFunc func(context.Context, Event) error + +func (f sinkFunc) Emit(ctx context.Context, e Event) error { return f(ctx, e) } + +type nilResultStreamer struct{} + +func (nilResultStreamer) Generate(context.Context, sdk.Request) (sdk.ModelResult, error) { + return sdk.ModelResult{}, errors.New("generate should not be called when streaming") +} + +func (nilResultStreamer) Stream(context.Context, sdk.Request) (sdk.ModelStream, error) { + parts := make(chan sdk.StreamPart) + close(parts) + return sdk.ModelStream{Parts: parts, Result: func() (*sdk.ModelResult, error) { return nil, nil }}, nil +} diff --git a/agent/run/loop/takeover_test.go b/agent/run/loop/takeover_test.go new file mode 100644 index 0000000..fc9160b --- /dev/null +++ b/agent/run/loop/takeover_test.go @@ -0,0 +1,89 @@ +package loop + +import ( + "context" + "errors" + "testing" + + . "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/sdk" +) + +// The owner process dies while a tool call is Executing. A new owner takes +// the Session over, RecoverInterrupted settles the call as Unknown, a fresh +// Loop finishes the Run, and the dead owner's late settlement is fenced with +// ErrOwnershipLost (RUN-CMT-6/7, RUN-LOP-5). +func TestTakeoverDisposesExecutingCallAndFencesOldOwner(t *testing.T) { + stack := newTestStack(t, nil) + stack.createRun(t, "run-1", AgentInput{ID: "seed", Payload: cj(`{}`)}) + oldRuntime := stack.runtime + + spec := toolSpec(t, "slow", DirectExecution) + block := make(chan struct{}) + started := make(chan struct{}, 1) + slow := &fakeTool{ref: "slow", def: toolDef(spec.Name), policy: DirectExecution, + execute: func(ctx context.Context, req ToolExecutionRequest) ToolExecutionOutcome { + started <- struct{}{} + <-block + return ToolExecutionSucceeded{Result: ToolExecutionResult{Output: req.Arguments}} + }} + call := sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 2}, + ToolCalls: []sdk.ToolCall{{ToolCallID: "c1", ToolName: "slow", Input: `{"x":1}`}}} + first, err := New(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{call}}}, fakeToolCatalog{map[ToolRef]ExecutableTool{"slow": slow}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + firstDone := make(chan error, 1) + go func() { + _, err := first.Run(context.Background(), oldRuntime, testSession, "run-1", nil) + firstDone <- err + }() + <-started + + // The old owner is presumed dead; a new owner opens with Takeover. + stack.open(t) + n, err := stack.runtime.RecoverInterrupted(context.Background(), testSession) + if err != nil || n != 1 { + t.Fatalf("RecoverInterrupted = %d %v, want 1", n, err) + } + again, err := stack.runtime.RecoverInterrupted(context.Background(), testSession) + if err != nil || again != 0 { + t.Fatalf("second RecoverInterrupted = %d %v, want 0", again, err) + } + snap := loadState(t, stack.runtime, "run-1") + if _, open := snap.State.Current.(Open); !open { + t.Fatalf("after takeover current = %T, want Open", snap.State.Current) + } + + second, err := New(fakeCatalog{&fakeInvoker{results: []sdk.ModelResult{textResult("done")}}}, fakeToolCatalog{map[ToolRef]ExecutableTool{"slow": slow}}, + staticPlanner{specs: []ToolSpec{spec}}, ExecutionPolicy{}, false) + if err != nil { + t.Fatal(err) + } + res, err := second.Run(context.Background(), stack.runtime, testSession, "run-1", nil) + if err != nil || res.Disposition != LoopFinished || res.Result.Status != RunCompleted { + t.Fatalf("second loop = %+v %v", res, err) + } + unknown := 0 + for _, f := range recordFacts(t, stack.runtime, "run-1") { + if failed, ok := f.(ToolCallFailed); ok && failed.Outcome == ToolOutcomeUnknown { + unknown++ + } + } + if unknown != 1 { + t.Fatalf("unknown settlements = %d, want 1", unknown) + } + + // The dead owner's worker finally returns: its settlement is fenced. + close(block) + if err := <-firstDone; !errors.Is(err, ErrOwnershipLost) { + t.Fatalf("old owner loop error = %v, want ErrOwnershipLost", err) + } + // Nothing of the old owner reached the stream after the takeover. + for _, f := range recordFacts(t, stack.runtime, "run-1") { + if _, ok := f.(ToolCallCompleted); ok { + t.Fatal("fenced worker's result reached the stream") + } + } +} diff --git a/agent/run/loop/tool.go b/agent/run/loop/tool.go new file mode 100644 index 0000000..53d7aa2 --- /dev/null +++ b/agent/run/loop/tool.go @@ -0,0 +1,238 @@ +package loop + +import ( + "context" + "fmt" + "sync" + + run "github.com/felinics/twilight/agent/run" +) + +type startedWorker struct { + call run.ToolCallState + base run.RunPosition + tool ExecutableTool + attempt attempt +} + +func toolCallIndex(step run.ToolStep, callID run.CallID) int { + for i := range step.Calls { + if step.Calls[i].CallID == callID { + return i + } + } + return -1 +} + +func (l *Loop) resolveExecutableTool(proto run.Protocol, call run.ToolCallState) (ExecutableTool, *run.ToolFailure) { + tool, resolveErr := l.Tools.ResolveTool(call.ToolRef) + if resolveErr != nil { + return nil, &run.ToolFailure{Class: run.FailureToolLookup, Message: resolveErr.Error()} + } + if tool == nil { + return nil, &run.ToolFailure{Class: run.FailureToolLookup, Message: "tool catalog returned a nil tool"} + } + toolDef, freezeErr := run.FreezeToolDefinition(tool.Definition()) + if freezeErr != nil { + return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: freezeErr.Error()} + } + defDigest, digestErr := proto.DigestToolDefinition(toolDef) + if digestErr != nil { + return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: digestErr.Error()} + } + switch { + case tool.Ref() != call.ToolRef || defDigest != call.DefinitionDigest: + return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "tool definition digest mismatch"} + case tool.ResponsePolicy() != call.Policy: + return nil, &run.ToolFailure{Class: run.FailureDefinitionMismatch, Message: "response policy mismatch"} + } + if argErr := tool.ValidateArguments(call.Arguments); argErr != nil { + return nil, &run.ToolFailure{Class: run.FailureInvalidArguments, Message: argErr.Error()} + } + return tool, nil +} + +func (l *Loop) runToolCalls(ctx context.Context, runtime boundRuntime, events EventSink, snapshot *run.RuntimeSnapshot, eff run.StartToolCalls) error { + runID := snapshot.State.RunID + proto, err := snapshot.Protocol() + if err != nil { + return err + } + ts, ok := snapshot.State.Current.(run.ToolStep) + if !ok || ts.RefValue.ID != eff.StepID { + return fmt.Errorf("agent: loop: tool step %q is not current", eff.StepID) + } + + limit := len(eff.CallIDs) + if ts.Scheduling.Mode == run.ToolScheduleSequential { + limit = 1 + } + if ts.Scheduling.MaxParallel > 0 && ts.Scheduling.MaxParallel < limit { + limit = ts.Scheduling.MaxParallel + } + var started []startedWorker + for _, callID := range eff.CallIDs { + if len(started) >= limit { + break + } + // Outer ctx cancelled: stop starting new calls; settle what we own. + if ctx.Err() != nil { + break + } + i := toolCallIndex(ts, callID) + if i < 0 { + continue + } + call := ts.Calls[i] + if call.Status != run.ToolPending { + // Executing calls belong to the worker that started them (this + // process) or to the owner's takeover disposition; never re-run. + continue + } + + tool, known := l.resolveExecutableTool(proto, call) + if known != nil { + // Known failure of a Pending call: no start barrier, no tool call, + // no claim. Its identity derives from the call alone; a retry of + // the same rejection is idempotent. + res, err := l.commit(ctx, runtime, runID, run.DeriveSettlementCommandID(runID, eff.StepID, callID, ""), snapshot.Position, + run.SubmitToolFailure{StepID: eff.StepID, CallID: callID, Failure: *known, Outcome: run.ToolOutcomeKnown}, proto) + if err != nil { + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) + if !retriable(err) { + return err + } + return settleErr + } + l.emitCommitted(ctx, events, runtime.sid, runID, res.Events) + continue + } + + a := newAttempt(runID, eff.StepID, callID) + start, err := l.commit(ctx, runtime, runID, a.startID(), snapshot.Position, + run.StartToolCall{StepID: eff.StepID, CallID: callID, Claim: a.claim}, proto) + if err != nil { + settleErr := l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) + if retriable(err) { + return settleErr // another actor moved the call; reload decides + } + return err + } + if startedCall, ok := toolCallFromSnapshot(start.Snapshot.State, eff.StepID, callID); !ok || startedCall.Status != run.ToolExecuting { + // The one-shot replay may land after the call was settled. Never + // invoke an effect for a call that is no longer Executing. + continue + } + l.emitCommitted(ctx, events, runtime.sid, runID, start.Events) + if events != nil { + _ = events.Emit(ctx, Event{Session: runtime.sid, RunID: runID, StepID: eff.StepID, CallID: callID, + Kind: EventToolStarted, Durability: EventCommitted}) + } + started = append(started, startedWorker{call: call, base: start.Snapshot.Position, tool: tool, attempt: a}) + } + + return l.settleWorkers(ctx, runtime, events, runID, eff.StepID, started, proto) +} + +func toolCallFromSnapshot(state run.MachineState, stepID run.StepID, callID run.CallID) (run.ToolCallState, bool) { + step, ok := state.Current.(run.ToolStep) + if !ok || step.RefValue.ID != stepID { + return run.ToolCallState{}, false + } + for _, call := range step.Calls { + if call.CallID == callID { + return call, true + } + } + return run.ToolCallState{}, false +} + +// settleWorkers executes every started worker and commits its outcome. An +// accepted start is never abandoned (RUN-LOP-4). Tool workers receive outer +// context cancellation; settlement uses a detached control context so the +// resulting outcome can still reach Runtime (RUN-LOP-5). Unknown settles +// only that call. A non-sentinel commit error leaves the same command in the +// local settlement cache for the next Run invocation. +func (l *Loop) settleWorkers(ctx context.Context, runtime boundRuntime, events EventSink, runID run.RunID, stepID run.StepID, started []startedWorker, proto run.Protocol) error { + if len(started) == 0 { + return nil + } + controlCtx := context.WithoutCancel(ctx) + var mu sync.Mutex + var wg sync.WaitGroup + var firstErr error + for i := range started { + wg.Add(1) + w := started[i] + go func(w startedWorker) { + defer wg.Done() + req := ToolExecutionRequest{ + RunID: runID, + StepID: stepID, + CallID: w.call.CallID, + ToolRef: w.call.ToolRef, + DefinitionDigest: w.call.DefinitionDigest, + Arguments: w.call.Arguments, + Progress: &progressSink{events: events, run: runID, step: stepID, call: w.call.CallID}, + } + outcome := executeToolSafely(ctx, w.tool, &req) + + var cmd run.AgentCommand + switch o := outcome.(type) { + case ToolExecutionSucceeded: + cmd = run.SubmitToolResult{StepID: stepID, CallID: w.call.CallID, Result: o.Result} + case ToolExecutionFailed: + failure := o.Failure + if failure.Class == "" || failure.Class == run.FailureEffectUnknown { + failure.Class = run.FailureExecution + } + cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: failure, Outcome: run.ToolOutcomeKnown} + case ToolExecutionUnknown: + failure := o.Failure + if failure.Class != "" && failure.Class != run.FailureEffectUnknown && failure.Message == "" { + failure.Message = "tool reported " + failure.Class + } + failure.Class = run.FailureEffectUnknown + cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, Failure: failure, Outcome: run.ToolOutcomeUnknown} + default: + cmd = run.SubmitToolFailure{StepID: stepID, CallID: w.call.CallID, + Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "tool returned no outcome"}, Outcome: run.ToolOutcomeUnknown} + } + + mu.Lock() + defer mu.Unlock() + // Commit on the worker's start base; stale bases rebase call-locally. + // Late results after terminal return ErrRunTerminal and are dropped + // (audit is the adapter's job). The one-shot same-CommandID replay + // lives inside l.commit. Tool settlements never terminate a Run; + // the result is ignored. + if _, err := l.settle(controlCtx, runtime, events, w.attempt, w.base, cmd, proto); err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("agent: loop: settling call %q: %w", w.call.CallID, err) + } + return + } + if events != nil { + _ = events.Emit(ctx, Event{Session: runtime.sid, RunID: runID, StepID: stepID, CallID: w.call.CallID, + Kind: EventToolCompleted, Durability: EventCommitted}) + } + }(w) + } + wg.Wait() + return firstErr +} + +// executeToolSafely runs an application tool and converts a panic into +// ToolExecutionUnknown: the effect may have happened before the panic, and a +// crashing tool must not take down every run in the process. +func executeToolSafely(ctx context.Context, tool ExecutableTool, req *ToolExecutionRequest) (outcome ToolExecutionOutcome) { + defer func() { + if r := recover(); r != nil { + outcome = ToolExecutionUnknown{Failure: run.ToolFailure{ + Class: run.FailureEffectUnknown, + Message: fmt.Sprintf("tool panic: %v", r), + }} + } + }() + return tool.Execute(ctx, *req) +} diff --git a/agent/run/machine_test.go b/agent/run/machine_test.go new file mode 100644 index 0000000..a87f315 --- /dev/null +++ b/agent/run/machine_test.go @@ -0,0 +1,739 @@ +package run + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/felinics/twilight/sdk" +) + +// --- helpers --- + +const testModel ModelRef = "m-1" + +func cj(raw string) CanonicalJSON { return MustParseCanonicalJSON(raw) } + +func newRun(t *testing.T) MachineState { + t.Helper() + s, err := InitializeRun("run-1", "", 0) + if err != nil { + t.Fatal(err) + } + return fold(t, s, mustDecide(t, s, AcceptInput{Input: AgentInput{ID: "seed", Payload: cj(`{"q":"hi"}`)}})) +} + +func mustDecide(t *testing.T, s MachineState, c AgentCommand) []Fact { + t.Helper() + facts, err := ProtocolV1().Decide(s, c) + if err != nil { + t.Fatalf("ProtocolV1().Decide(%T): %v", c, err) + } + return facts +} + +func fold(t *testing.T, s MachineState, facts []Fact) MachineState { + t.Helper() + for _, f := range facts { + var err error + s, err = ProtocolV1().Evolve(s, f) + if err != nil { + t.Fatalf("ProtocolV1().Evolve(%T): %v", f, err) + } + } + return s +} + +func testRequest(tools ...sdk.ToolDefinition) sdk.Request { + return sdk.Request{ + Model: "m-1", + Messages: []sdk.Message{sdk.UserMessage("hi")}, + Tools: tools, + } +} + +func testToolDef(name string) sdk.ToolDefinition { + return sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} +} + +func buildPrepare(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpec) (PrepareModelRequest, CommandID) { + t.Helper() + frozenReq, err := FreezeModelRequest(req) + if err != nil { + t.Fatal(err) + } + reqDigest, err := ProtocolV1().DigestRequest(frozenReq) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := ProtocolV1().DigestToolSpecs(specs) + if err != nil { + t.Fatal(err) + } + model := ModelRef(frozenReq.Model) + binding, err := ProtocolV1().DigestModelStepBinding(model, reqDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + cmdID := DeriveModelRequestCommandID(s.RunID, 0) + stepID := DeriveModelStepID(s.RunID, cmdID, binding) + ids := make([]InputID, len(s.PendingInputs)) + for i, in := range s.PendingInputs { + ids[i] = in.ID + } + return PrepareModelRequest{ + StepID: stepID, + Model: model, + Request: frozenReq, + RequestDigest: reqDigest, + InputIDs: ids, + Tools: specs, + ToolsDigest: toolsDigest, + }, cmdID +} + +func makeSpec(t *testing.T, def sdk.ToolDefinition, policy ResponsePolicy) ToolSpec { + t.Helper() + frozen, err := FreezeToolDefinition(def) + if err != nil { + t.Fatal(err) + } + d, err := ProtocolV1().DigestToolDefinition(frozen) + if err != nil { + t.Fatal(err) + } + return ToolSpec{Ref: ToolRef(def.Name), Name: def.Name, DefinitionDigest: d, Policy: policy} +} + +func responseDecisionDigest(t *testing.T, kind ResponseKind, decision ResponseDecision, reason string) Digest { + t.Helper() + d, err := ProtocolV1().DigestToolResponseDecision(kind, decision, reason) + if err != nil { + t.Fatal(err) + } + return d +} + +func responsePayloadDigest(t *testing.T, payload CanonicalJSON) Digest { + t.Helper() + d, err := ProtocolV1().DigestToolResponsePayload(payload) + if err != nil { + t.Fatal(err) + } + return d +} + +// makeBinding builds the binding for the index-th tool call of source, whose +// provider id is providerID. Tests address calls by the derived CallID. +func makeBinding(t *testing.T, source StepID, index int, providerID string, spec ToolSpec, args string) ToolCallBinding { + t.Helper() + parsedArgs := cj(args) + callID := DeriveCallID(source, index) + bd, err := digestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, parsedArgs) + if err != nil { + t.Fatal(err) + } + return ToolCallBinding{ + CallID: callID, + ProviderCallID: providerID, + ToolRef: spec.Ref, + DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bd, + Arguments: parsedArgs, + Policy: spec.Policy, + } +} + +// cid is the derived CallID of the index-th call of a step. +func cid(step StepID, index int) CallID { return DeriveCallID(step, index) } + +func modelResultWithCalls(callIDs ...string) ModelResult { + return modelResultWithNamedCalls("t", `{}`, callIDs...) +} + +// modelResultWithNamedCalls builds a result whose tool calls carry the given +// tool name and argument text — bindings must cross-check against these. +func modelResultWithNamedCalls(toolName, args string, callIDs ...string) ModelResult { + r := sdk.ModelResult{ + Text: "", + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}, + } + for _, id := range callIDs { + r.ToolCalls = append(r.ToolCalls, sdk.ToolCall{ToolCallID: id, ToolName: toolName, Input: args}) + } + frozen, err := FreezeModelResult(r) + if err != nil { + panic(err) + } + return frozen +} + +// advance runs prepare+start and returns the state in Executing plus stepID. +func advanceToExecuting(t *testing.T, s MachineState, req sdk.Request, specs []ToolSpec) (MachineState, StepID) { + t.Helper() + prep, _ := buildPrepare(t, s, req, specs) + s = fold(t, s, mustDecide(t, s, prep)) + s = fold(t, s, mustDecide(t, s, StartModelExecution{StepID: prep.StepID})) + return s, prep.StepID +} + +// --- tests --- + +func TestInitializeRunIsMinimal(t *testing.T) { + s, err := InitializeRun("r", "turn-1", 1) + if err != nil { + t.Fatal(err) + } + if s.RunID != "r" || s.Owner != "turn-1" || s.Attempt != 1 || s.Status != RunActive || !atOpen(s.Current) || len(s.PendingInputs) != 0 { + t.Fatalf("initial state = %+v", s) + } +} + +func TestRunCreatedFoldsOntoZeroState(t *testing.T) { + newRun, err := BuildNewRunFor("r", "turn-1", 2, "cause") + if err != nil { + t.Fatal(err) + } + facts, err := ProtocolV1().BuildCreateGroup(newRun, []AgentInput{{ID: "in-1", Payload: cj(`1`)}}) + if err != nil { + t.Fatal(err) + } + if len(facts) != 2 { + t.Fatalf("facts = %d, want [created, input_accepted]", len(facts)) + } + s := fold(t, MachineState{}, facts) + if s.RunID != "r" || s.Owner != "turn-1" || s.Attempt != 2 || !atOpen(s.Current) || len(s.PendingInputs) != 1 { + t.Fatalf("state after create group = %+v", s) + } + if _, err := ProtocolV1().Evolve(s, facts[0]); err == nil { + t.Fatal("second RunCreated folded") + } + if _, err := ProtocolV1().Evolve(MachineState{}, facts[1]); err == nil { + t.Fatal("InputAccepted folded before RunCreated") + } +} + +func TestNextOnFreshRunNeedsModelRequest(t *testing.T) { + s := newRun(t) + eff, err := Next(s) + if err != nil { + t.Fatal(err) + } + need, ok := eff.(NeedModelRequest) + if !ok { + t.Fatalf("effect = %T, want NeedModelRequest", eff) + } + if len(need.Hint.Inputs) != 1 || need.Hint.Inputs[0].ID != "seed" { + t.Fatalf("hint inputs = %+v", need.Hint.Inputs) + } +} + +func TestPrepareConsumesInputsAndCounts(t *testing.T) { + s := newRun(t) + prep, _ := buildPrepare(t, s, testRequest(), nil) + facts := mustDecide(t, s, prep) + if len(facts) != 1 { + t.Fatalf("facts = %d, want 1", len(facts)) + } + s = fold(t, s, facts) + if len(s.PendingInputs) != 0 { + t.Fatal("pending inputs not consumed") + } + if s.ModelSteps != 1 { + t.Fatalf("ModelSteps = %d", s.ModelSteps) + } + if ms, ok := s.Current.(ModelStep); !ok || ms.Status != ModelPrepared { + t.Fatalf("current = %#v", s.Current) + } +} + +func TestPrepareRejectsIncompleteInputIDs(t *testing.T) { + s := newRun(t) + prep, _ := buildPrepare(t, s, testRequest(), nil) + prep.InputIDs = nil + if _, err := ProtocolV1().Decide(s, prep); err == nil { + t.Fatal("prepare with missing InputIDs accepted") + } +} + +func TestModelCompleteWithToolsOpensToolStep(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + + b := makeBinding(t, stepID, 0, "c1", spec, `{"x":1}`) + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("t", `{"x":1}`, "c1"), Calls: []ToolCallBinding{b}}) + if len(facts) != 2 { + t.Fatalf("facts = %d, want [completed, opened]", len(facts)) + } + opened, ok := facts[1].(ToolStepOpened) + if !ok { + t.Fatalf("facts[1] = %T", facts[1]) + } + if opened.Source != stepID { + t.Fatal("tool step source mismatch") + } + s = fold(t, s, facts) + ts, ok := s.Current.(ToolStep) + if !ok { + t.Fatalf("current = %T", s.Current) + } + if len(ts.Calls) != 1 || ts.Calls[0].Status != ToolPending { + t.Fatalf("calls = %+v", ts.Calls) + } + if err := ValidateToolCallState(ts.Calls[0]); err != nil { + t.Fatal(err) + } +} + +func TestExternalResponseRequiresPayloadDigest(t *testing.T) { + def := testToolDef("ask") + spec := makeSpec(t, def, ExternalResponse) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("ask", `{}`, "c1"), Calls: []ToolCallBinding{b}}) + opened := facts[1].(ToolStepOpened) + s = fold(t, s, facts) + respID := opened.Calls[0].Response.ID + payload := cj(`{"answer":"ok"}`) + if _, err := ProtocolV1().Decide(s, SubmitToolResponse{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, ResponseDigest: "sha256:bad", Payload: payload}); err == nil { + t.Fatal("external response with bad payload digest accepted") + } + facts = mustDecide(t, s, SubmitToolResponse{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, + ResponseDigest: responsePayloadDigest(t, payload), Payload: payload}) + if len(facts) != 1 { + t.Fatalf("facts = %d, want [answered]", len(facts)) + } + + s = newRun(t) + s, stepID = advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b = makeBinding(t, stepID, 0, "c1", spec, `{}`) + facts = mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithNamedCalls("ask", `{}`, "c1"), Calls: []ToolCallBinding{b}}) + opened = facts[1].(ToolStepOpened) + s = fold(t, s, facts) + respID = opened.Calls[0].Response.ID + facts, err := ProtocolV1().Decide(s, RejectToolCall{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, + ResponseDigest: responseDecisionDigest(t, ResponseExternal, ResponseDecisionRejected, "user dismissed"), Reason: "user dismissed"}) + if err != nil { + t.Fatal(err) + } + failed := facts[0].(ToolCallFailed) + if failed.Failure.Class != FailureResponseRejected || failed.Outcome != ToolOutcomeKnown { + t.Fatalf("failed = %+v", failed) + } + s = fold(t, s, facts) + if s.Status != RunActive || !atOpen(s.Current) { + t.Fatal("run should continue after rejecting the external response") + } +} + +func TestToolSchedulingFrozenOnToolStepOpened(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) + facts := mustDecide(t, s, SubmitModelResult{ + StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}, + Scheduling: ToolScheduling{Mode: ToolScheduleSequential, MaxParallel: 1}, + }) + s = fold(t, s, facts) + ts := s.Current.(ToolStep) + if ts.Scheduling.Mode != ToolScheduleSequential || ts.Scheduling.MaxParallel != 1 { + t.Fatalf("scheduling = %+v", ts.Scheduling) + } +} + +func TestToolSchedulingRejectsUnknownMode(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) + _, err := ProtocolV1().Decide(s, SubmitModelResult{ + StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}, + Scheduling: ToolScheduling{Mode: "round-robin"}, + }) + if err == nil { + t.Fatal("unknown scheduling mode accepted") + } +} + +func TestParallelWaitingDoesNotBlockPending(t *testing.T) { + defA, defB := testToolDef("a"), testToolDef("b") + specA := makeSpec(t, defA, ApprovalRequired) + specB := makeSpec(t, defB, DirectExecution) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []ToolSpec{specA, specB}) + + bA := makeBinding(t, stepID, 0, "cA", specA, `{}`) + bB := makeBinding(t, stepID, 1, "cB", specB, `{}`) + r, err := FreezeModelResult(sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{TotalTokens: 15}, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "a", Input: `{}`}, + {ToolCallID: "cB", ToolName: "b", Input: `{}`}, + }, + }) + if err != nil { + t.Fatal(err) + } + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: r, Calls: []ToolCallBinding{bA, bB}}) + opened := facts[1].(ToolStepOpened) + s = fold(t, s, facts) + + eff, err := Next(s) + if err != nil { + t.Fatal(err) + } + start, ok := eff.(StartToolCalls) + if !ok || len(start.CallIDs) != 1 || start.CallIDs[0] != cid(stepID, 1) { + t.Fatalf("effect = %#v, want StartToolCalls[cB]", eff) + } + + // Complete B; step must stay open because A is Waiting. + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: cid(stepID, 1)})) + facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: cid(stepID, 1), Result: ToolExecutionResult{Output: cj(`"ok"`)}}) + if len(facts) != 1 { + t.Fatalf("facts = %d, step must not close with A waiting", len(facts)) + } + s = fold(t, s, facts) + + eff, err = Next(s) + if err != nil { + t.Fatal(err) + } + if _, ok := eff.(Idle); !ok { + t.Fatalf("effect after B completed = %#v, want Idle", eff) + } + if reqs := WaitingCalls(s); len(reqs) != 1 || reqs[0].CallID != cid(stepID, 0) { + t.Fatalf("WaitingCalls = %#v", WaitingCalls(s)) + } + + // Answer A via approval; approving moves to Pending, then completing it + // implicitly closes the step. + respID := opened.Calls[0].Response.ID + s = fold(t, s, mustDecide(t, s, ApproveToolCall{StepID: opened.StepID, CallID: cid(stepID, 0), ResponseID: respID, + ResponseDigest: responseDecisionDigest(t, ResponseApproval, ResponseDecisionApproved, "")})) + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: cid(stepID, 0)})) + facts = mustDecide(t, s, SubmitToolResult{StepID: opened.StepID, CallID: cid(stepID, 0), Result: ToolExecutionResult{Output: cj(`"done"`)}}) + if len(facts) != 1 { + t.Fatalf("facts = %d, want [completed]", len(facts)) + } + s = fold(t, s, facts) + if !atOpen(s.Current) { + t.Fatal("tool step should be closed") + } +} + +func TestUnknownToolFailureSettlesOnlyThatCall(t *testing.T) { + defA, defB := testToolDef("a"), testToolDef("b") + specA := makeSpec(t, defA, DirectExecution) + specB := makeSpec(t, defB, DirectExecution) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(defA, defB), []ToolSpec{specA, specB}) + + bA := makeBinding(t, stepID, 0, "cA", specA, `{}`) + bB := makeBinding(t, stepID, 1, "cB", specB, `{}`) + r, err := FreezeModelResult(sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{TotalTokens: 2}, + ToolCalls: []sdk.ToolCall{ + {ToolCallID: "cA", ToolName: "a", Input: `{}`}, + {ToolCallID: "cB", ToolName: "b", Input: `{}`}, + }, + }) + if err != nil { + t.Fatal(err) + } + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: r, Calls: []ToolCallBinding{bA, bB}}) + opened := facts[1].(ToolStepOpened) + s = fold(t, s, facts) + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: cid(stepID, 0)})) + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: cid(stepID, 1)})) + + facts = mustDecide(t, s, SubmitToolFailure{ + StepID: opened.StepID, + CallID: cid(stepID, 0), + Failure: ToolFailure{Class: FailureEffectUnknown, Message: "lost"}, + Outcome: ToolOutcomeUnknown, + }) + if len(facts) != 1 { + t.Fatalf("facts = %d, want [ToolCallFailed]", len(facts)) + } + failed := facts[0].(ToolCallFailed) + if failed.CallID != cid(stepID, 0) || failed.Outcome != ToolOutcomeUnknown { + t.Fatalf("failed = %+v", failed) + } + s = fold(t, s, facts) + if s.Status != RunActive { + t.Fatalf("status = %v, want active", s.Status) + } + ts, ok := s.Current.(ToolStep) + if !ok { + t.Fatalf("current = %T, want ToolStep", s.Current) + } + if ts.Calls[0].Status != ToolFailed || ts.Calls[1].Status != ToolExecuting { + t.Fatalf("calls = %+v", ts.Calls) + } + + s = fold(t, s, mustDecide(t, s, SubmitToolResult{ + StepID: opened.StepID, CallID: cid(stepID, 1), Result: ToolExecutionResult{Output: cj(`"ok"`)}, + })) + if s.Status != RunActive || !atOpen(s.Current) { + t.Fatalf("after sibling complete: status=%v current=%T", s.Status, s.Current) + } + if s.LastToolStep == nil || s.LastToolStep.Calls[0].Status != ToolFailed || s.LastToolStep.Calls[1].Status != ToolCompleted { + t.Fatalf("LastToolStep = %+v", s.LastToolStep) + } +} + +func TestRejectModelResultDispositionRetriesThenFails(t *testing.T) { + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + + usage := Usage{TotalTokens: 3} + // Reject 1: back to Prepared. + facts := mustDecide(t, s, RejectModelResult{StepID: stepID, Usage: usage, Failure: StepFailure{Class: FailureMalformedModel}}) + if len(facts) != 1 { + t.Fatalf("facts = %d", len(facts)) + } + s = fold(t, s, facts) + if ms := s.Current.(ModelStep); ms.Status != ModelPrepared || ms.Rejects != 1 { + t.Fatalf("model step = %+v", ms) + } + if s.Usage.TotalTokens != 3 { + t.Fatal("usage not accumulated on reject") + } + + // Start again, reject 2: host policy still chooses retry. + s = fold(t, s, mustDecide(t, s, StartModelExecution{StepID: stepID})) + s = fold(t, s, mustDecide(t, s, RejectModelResult{StepID: stepID, Usage: usage, Failure: StepFailure{Class: FailureMalformedModel}})) + if ms := s.Current.(ModelStep); ms.Rejects != 2 { + t.Fatalf("rejects = %d", ms.Rejects) + } + + // Third reject: host policy chooses fail-run disposition. + s = fold(t, s, mustDecide(t, s, StartModelExecution{StepID: stepID})) + facts = mustDecide(t, s, RejectModelResult{StepID: stepID, Usage: usage, Failure: StepFailure{Class: FailureMalformedModel}, Disposition: ModelRejectFailRun}) + if len(facts) != 2 { + t.Fatalf("facts = %d, want [rejected, ended]", len(facts)) + } + s = fold(t, s, facts) + if s.Status != RunFailed || s.Result.Reason != ReasonMalformedModel { + t.Fatalf("result = %+v", s.Result) + } + if s.Usage.TotalTokens != 9 { + t.Fatalf("usage = %d, want 9", s.Usage.TotalTokens) + } +} + +func TestAcceptInputDuplicateIsGuarded(t *testing.T) { + s := newRun(t) + facts := mustDecide(t, s, NextStep(AgentInput{ID: "in-2", Payload: cj(`1`)})) + s = fold(t, s, facts) + if len(s.PendingInputs) != 2 { + t.Fatalf("pending = %d", len(s.PendingInputs)) + } + // Decide rejects a duplicate and an exact command replay never reaches + // Evolve, so a persisted duplicate InputAccepted is a corrupt log: the + // guard refuses it instead of silently deduplicating. + if _, err := ProtocolV1().Evolve(s, facts[0]); err == nil { + t.Fatal("duplicate InputAccepted folded silently") + } +} + +// Inputs queue in every non-terminal state (RUN-MCH-4). A Prepared step whose +// request predates the input is withdrawn and replanned; an Executing step +// keeps the input for the Open that follows it. +func TestAcceptInputQueuesInAnyActiveState(t *testing.T) { + s := newRun(t) + prep, _ := buildPrepare(t, s, testRequest(), nil) + s = fold(t, s, mustDecide(t, s, prep)) + + // Prepared: input queues, Next withdraws, withdraw reopens with the input. + s = fold(t, s, mustDecide(t, s, NextStep(AgentInput{ID: "in-3", Payload: cj(`3`)}))) + if len(s.PendingInputs) != 1 || s.PendingInputs[0].ID != "in-3" { + t.Fatalf("pending after accept while Prepared = %+v", s.PendingInputs) + } + eff, err := Next(s) + if err != nil { + t.Fatal(err) + } + withdraw, ok := eff.(WithdrawPrepared) + if !ok || withdraw.StepID != prep.StepID { + t.Fatalf("effect = %#v, want WithdrawPrepared", eff) + } + facts := mustDecide(t, s, WithdrawPreparedStep{StepID: prep.StepID}) + if len(facts) != 1 { + t.Fatalf("facts = %d, want [withdrawn]", len(facts)) + } + s = fold(t, s, facts) + if !atOpen(s.Current) || s.ModelSteps != 0 || len(s.PendingInputs) != 1 { + t.Fatalf("state after withdraw = %+v", s) + } + // Withdraw without pending inputs is rejected: the request is complete. + prep2, _ := buildPrepare(t, s, testRequest(), nil) + s = fold(t, s, mustDecide(t, s, prep2)) + if _, err := ProtocolV1().Decide(s, WithdrawPreparedStep{StepID: prep2.StepID}); err == nil { + t.Fatal("withdraw accepted with no pending inputs") + } + + // Executing: input queues, Next stays Idle, no tool calls + pending input + // returns to Open instead of ending the Run. + s = fold(t, s, mustDecide(t, s, StartModelExecution{StepID: prep2.StepID})) + s = fold(t, s, mustDecide(t, s, NextStep(AgentInput{ID: "in-4", Payload: cj(`4`)}))) + if eff, _ := Next(s); eff != (Idle{}) { + t.Fatalf("effect while Executing with pending input = %#v, want Idle", eff) + } + result, err := FreezeModelResult(sdk.ModelResult{Text: "answer", FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}) + if err != nil { + t.Fatal(err) + } + facts = mustDecide(t, s, SubmitModelResult{StepID: prep2.StepID, Result: result}) + if len(facts) != 1 { + t.Fatalf("facts = %d, want [completed] without RunEnded while inputs are pending", len(facts)) + } + completed := facts[0].(ModelStepCompleted) + wantDigest, err := ProtocolV1().DigestModelResult(result) + if err != nil { + t.Fatal(err) + } + if completed.ResultDigest != wantDigest || completed.Usage.TotalTokens != 1 || completed.FinishReason != FinishReasonStop { + t.Fatalf("completed = %+v", completed) + } + s = fold(t, s, facts) + if s.Status != RunActive || !atOpen(s.Current) || len(s.PendingInputs) != 1 || s.PendingInputs[0].ID != "in-4" { + t.Fatalf("state after completed with pending input = %+v", s) + } + if eff, _ := Next(s); eff == nil { + t.Fatal("no effect at Open") + } else if _, ok := eff.(NeedModelRequest); !ok { + t.Fatalf("effect = %#v, want NeedModelRequest", eff) + } +} + +func TestAcceptInputRejectsSeedDuplicateID(t *testing.T) { + s := newRun(t) + _, err := ProtocolV1().Decide(s, NextStep(AgentInput{ID: "seed", Payload: cj(`{"q":"other"}`)})) + if !errors.Is(err, ErrCommandConflict) { + t.Fatalf("duplicate seed input err = %v, want ErrCommandConflict", err) + } +} + +func TestEvolvePreparedRequiresCompleteOrderedPendingInputs(t *testing.T) { + minimal, err := InitializeRun("run-1", "", 0) + if err != nil { + t.Fatal(err) + } + withInputs := func(ids ...InputID) MachineState { + t.Helper() + s := minimal + for _, id := range ids { + var foldErr error + s, foldErr = ProtocolV1().Evolve(s, InputAccepted{Input: AgentInput{ID: id, Payload: cj(`null`)}}) + if foldErr != nil { + t.Fatal(foldErr) + } + } + return s + } + prepared := func(ids ...InputID) ModelStepPrepared { + request := ModelRequest{Model: string(testModel)} + requestDigest, err := ProtocolV1().DigestRequest(request) + if err != nil { + t.Fatal(err) + } + toolsDigest, err := ProtocolV1().DigestToolSpecs(nil) + if err != nil { + t.Fatal(err) + } + binding, err := ProtocolV1().DigestModelStepBinding(testModel, requestDigest, toolsDigest) + if err != nil { + t.Fatal(err) + } + return ModelStepPrepared{StepID: "step-1", Model: testModel, RequestDigest: requestDigest, ToolsDigest: toolsDigest, BindingDigest: binding, InputIDs: ids} + } + + t.Run("nonexistent input", func(t *testing.T) { + s := withInputs("in-1") + if _, err := ProtocolV1().Evolve(s, prepared("missing")); err == nil { + t.Fatal("ModelStepPrepared consuming a nonexistent input folded") + } + }) + t.Run("length mismatch", func(t *testing.T) { + s := withInputs("in-1", "in-2") + if _, err := ProtocolV1().Evolve(s, prepared("in-1")); err == nil { + t.Fatal("ModelStepPrepared consuming only a pending-input prefix folded") + } + }) + t.Run("order mismatch", func(t *testing.T) { + s := withInputs("in-1", "in-2") + if _, err := ProtocolV1().Evolve(s, prepared("in-2", "in-1")); err == nil { + t.Fatal("ModelStepPrepared consuming pending inputs out of order folded") + } + }) + t.Run("complete ordered IDs", func(t *testing.T) { + s := withInputs("in-1", "in-2") + next, err := ProtocolV1().Evolve(s, prepared("in-1", "in-2")) + if err != nil { + t.Fatal(err) + } + if _, ok := next.Current.(ModelStep); !ok || len(next.PendingInputs) != 0 { + t.Fatalf("prepared state = %+v", next) + } + }) +} + +func TestEvolveRejectsModelPrepareOverCurrentStep(t *testing.T) { + s := newRun(t) + s, _ = advanceToExecuting(t, s, testRequest(), nil) + _, err := ProtocolV1().Evolve(s, ModelStepPrepared{ + StepID: "other", + Model: testModel, + RequestDigest: "sha256:req", + ToolsDigest: "sha256:tools", + BindingDigest: "sha256:binding", + }) + if err == nil { + t.Fatal("Evolve accepted ModelStepPrepared over an existing step") + } +} + +// A provider that repeats or omits tool_call_id cannot break Run identity: +// the derived CallID is unique per (step, index) and the provider id rides +// along as ProviderCallID. +func TestDerivedCallIDToleratesProviderIDReuse(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + result := modelResultWithNamedCalls("t", `{}`, "call_0", "call_0", "") + bindings := []ToolCallBinding{ + makeBinding(t, stepID, 0, "call_0", spec, `{}`), + makeBinding(t, stepID, 1, "call_0", spec, `{}`), + makeBinding(t, stepID, 2, "", spec, `{}`), + } + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: result, Calls: bindings}) + opened := facts[1].(ToolStepOpened) + seen := map[CallID]bool{} + for i, c := range opened.Calls { + if c.CallID != cid(stepID, i) || seen[c.CallID] { + t.Fatalf("call %d id = %s", i, c.CallID) + } + seen[c.CallID] = true + } + if opened.Calls[0].ProviderCallID != "call_0" || opened.Calls[2].ProviderCallID != "" { + t.Fatalf("provider ids = %+v", opened.Calls) + } + // A binding whose CallID is not the derived one is rejected. + forged := bindings + forged[1].CallID = "call_0" + if _, err := ProtocolV1().Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: forged}); err == nil { + t.Fatal("non-derived CallID accepted") + } +} diff --git a/agent/run/model_data.go b/agent/run/model_data.go new file mode 100644 index 0000000..8791ccf --- /dev/null +++ b/agent/run/model_data.go @@ -0,0 +1,885 @@ +package run + +import ( + "encoding/json" + "fmt" + "time" + "unicode/utf8" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/felinics/twilight/sdk" +) + +// ProviderMetadata is the agent's persisted representation of provider-owned +// opaque metadata. Each namespace value is immutable canonical JSON: callers +// may keep mutating their sdk map, but runtime events/state own these values. +type ProviderMetadata map[string]CanonicalJSON + +type CacheControl struct { + Type string `json:"type"` + TTL string `json:"ttl,omitempty"` +} + +type MessageRole string + +const ( + MessageRoleUser MessageRole = "user" + MessageRoleAssistant MessageRole = "assistant" + MessageRoleSystem MessageRole = "system" + MessageRoleTool MessageRole = "tool" + MessageRoleDeveloper MessageRole = "developer" +) + +type MessagePartType string + +const ( + MessagePartTypeText MessagePartType = "text" + MessagePartTypeReasoning MessagePartType = "reasoning" + MessagePartTypeImage MessagePartType = "image" + MessagePartTypeFile MessagePartType = "file" + MessagePartTypeToolCall MessagePartType = "tool-call" + MessagePartTypeToolResult MessagePartType = "tool-result" +) + +type ReasoningFormat string + +const ( + ReasoningFormatUnknown ReasoningFormat = "" + ReasoningFormatAnthropic ReasoningFormat = "anthropic-v1" + ReasoningFormatOpenAIResponses ReasoningFormat = "openai-responses-v1" + ReasoningFormatGoogle ReasoningFormat = "google-v1" + ReasoningFormatCopilot ReasoningFormat = "copilot-v1" + ReasoningFormatOpenAIChat ReasoningFormat = "openai-chat-v1" +) + +// MessagePart is a closed, JSON-stable persisted content block. SDK message +// parts are interface values; the Runtime never stores that open interface. +type MessagePart struct { + Type MessagePartType `json:"type"` + + // Text / reasoning. + Text string `json:"text,omitempty"` + + // Reasoning-only identity/provenance. + ID string `json:"id,omitempty"` + Format ReasoningFormat `json:"format,omitempty"` + Model string `json:"model,omitempty"` + + // Image / file. + Image string `json:"image,omitempty"` + Data string `json:"data,omitempty"` + MediaType string `json:"mediaType,omitempty"` + Filename string `json:"filename,omitempty"` + + // Tool call / result. + ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` + Input CanonicalJSON `json:"input,omitzero"` + Result CanonicalJSON `json:"result,omitzero"` + IsError bool `json:"isError,omitempty"` + + CacheControl *CacheControl `json:"cacheControl,omitempty"` + ProviderMetadata ProviderMetadata `json:"providerMetadata,omitempty"` +} + +type Message struct { + Role MessageRole `json:"role"` + Content []MessagePart `json:"content"` + Usage *Usage `json:"usage,omitempty"` +} + +type ResponseFormatType string + +const ( + ResponseFormatText ResponseFormatType = "text" + ResponseFormatJSONObject ResponseFormatType = "json_object" + ResponseFormatJSONSchema ResponseFormatType = "json_schema" +) + +type ResponseFormat struct { + Type ResponseFormatType `json:"type"` + JSONSchema CanonicalJSON `json:"jsonSchema,omitzero"` +} + +type ToolChoiceMode string + +const ( + ToolChoiceAuto ToolChoiceMode = "auto" + ToolChoiceNone ToolChoiceMode = "none" + ToolChoiceRequired ToolChoiceMode = "required" + ToolChoiceTool ToolChoiceMode = "tool" +) + +type ToolChoice struct { + Mode ToolChoiceMode `json:"mode,omitempty"` + Tool string `json:"tool,omitempty"` +} + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters CanonicalJSON `json:"parameters"` + CacheControl *CacheControl `json:"cacheControl,omitempty"` +} + +// ModelRequest is the complete persisted input of one model call. It is the +// agent-owned mirror of sdk.Request with no open SDK interfaces or any fields. +type ModelRequest struct { + Model string `json:"model"` + System string `json:"system,omitempty"` + Messages []Message `json:"messages,omitempty"` + + Tools []ToolDefinition `json:"tools,omitempty"` + ToolChoice ToolChoice `json:"toolChoice,omitzero"` + + ResponseFormat *ResponseFormat `json:"responseFormat,omitempty"` + + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + MaxTokens *int `json:"maxTokens,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"` + PresencePenalty *float64 `json:"presencePenalty,omitempty"` + Seed *int `json:"seed,omitempty"` + ReasoningEffort *string `json:"reasoningEffort,omitempty"` + ReasoningSummary *string `json:"reasoningSummary,omitempty"` + PromptCacheKey *string `json:"promptCacheKey,omitempty"` + + ProviderOptions map[string]CanonicalJSON `json:"providerOptions,omitempty"` +} + +type FinishReason string + +const ( + FinishReasonStop FinishReason = "stop" + FinishReasonLength FinishReason = "length" + FinishReasonContentFilter FinishReason = "content-filter" + FinishReasonToolCalls FinishReason = "tool-calls" + FinishReasonError FinishReason = "error" + FinishReasonOther FinishReason = "other" + FinishReasonUnknown FinishReason = "unknown" +) + +type InputTokenDetail struct { + NoCacheTokens int `json:"noCacheTokens"` + CacheReadTokens int `json:"cacheReadTokens"` + CacheWriteTokens int `json:"cacheWriteTokens"` + CacheWrite5mTokens int `json:"cacheWrite5mTokens,omitempty"` + CacheWrite1hTokens int `json:"cacheWrite1hTokens,omitempty"` +} + +type OutputTokenDetail struct { + TextTokens int `json:"textTokens"` + ReasoningTokens int `json:"reasoningTokens"` +} + +type Usage struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + TotalTokens int `json:"totalTokens"` + ReasoningTokens int `json:"reasoningTokens,omitempty"` + CachedInputTokens int `json:"cachedInputTokens,omitempty"` + InputTokenDetails InputTokenDetail `json:"inputTokenDetails,omitempty"` + OutputTokenDetails OutputTokenDetail `json:"outputTokenDetails,omitempty"` +} + +//nolint:gocritic // hugeParam: Add is a pure value operation and must not mutate caller-owned Usage. +func (u Usage) Add(other Usage) Usage { + u.InputTokens += other.InputTokens + u.OutputTokens += other.OutputTokens + u.TotalTokens += other.TotalTokens + u.ReasoningTokens += other.ReasoningTokens + u.CachedInputTokens += other.CachedInputTokens + u.InputTokenDetails.NoCacheTokens += other.InputTokenDetails.NoCacheTokens + u.InputTokenDetails.CacheReadTokens += other.InputTokenDetails.CacheReadTokens + u.InputTokenDetails.CacheWriteTokens += other.InputTokenDetails.CacheWriteTokens + u.InputTokenDetails.CacheWrite5mTokens += other.InputTokenDetails.CacheWrite5mTokens + u.InputTokenDetails.CacheWrite1hTokens += other.InputTokenDetails.CacheWrite1hTokens + u.OutputTokenDetails.TextTokens += other.OutputTokenDetails.TextTokens + u.OutputTokenDetails.ReasoningTokens += other.OutputTokenDetails.ReasoningTokens + return u +} + +type ReasoningPart struct { + ID string `json:"id,omitempty"` + Text string `json:"text"` + Format ReasoningFormat `json:"format,omitempty"` + Model string `json:"model,omitempty"` + ProviderMetadata ProviderMetadata `json:"providerMetadata,omitempty"` +} + +type Source struct { + SourceType string `json:"sourceType"` + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title,omitempty"` + ProviderMetadata ProviderMetadata `json:"providerMetadata,omitempty"` +} + +type GeneratedFile struct { + Data string `json:"data"` + MediaType string `json:"mediaType"` +} + +type ModelToolCall struct { + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Input CanonicalJSON `json:"input"` + ProviderMetadata ProviderMetadata `json:"providerMetadata,omitempty"` +} + +type ResponseMetadata struct { + ID string `json:"id,omitempty"` + ModelID string `json:"modelId,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +// ModelResult is the persisted output of one model call. It mirrors +// sdk.ModelResult as agent-owned JSON-stable value types. +type ModelResult struct { + Text string `json:"text"` + Reasoning string `json:"reasoning,omitempty"` + ReasoningParts []ReasoningPart `json:"reasoningParts,omitempty"` + TextProviderMetadata ProviderMetadata `json:"textProviderMetadata,omitempty"` + + FinishReason FinishReason `json:"finishReason"` + RawFinishReason string `json:"rawFinishReason,omitempty"` + Usage Usage `json:"usage"` + + Sources []Source `json:"sources,omitempty"` + Files []GeneratedFile `json:"files,omitempty"` + ToolCalls []ModelToolCall `json:"toolCalls,omitempty"` + + Response *ResponseMetadata `json:"response,omitempty"` +} + +func freezeRawJSON(raw json.RawMessage) (CanonicalJSON, error) { + return ParseCanonicalJSON(raw) +} + +func freezeJSONValue(v any) (CanonicalJSON, error) { + return CanonicalJSONFromValue(v) +} + +func decodeJSONValue(raw CanonicalJSON) (any, error) { + return raw.Any() +} + +func FreezeProviderMetadata(meta map[string]any) (ProviderMetadata, error) { + if meta == nil { + return nil, nil + } + out := make(ProviderMetadata, len(meta)) + for k, v := range meta { + raw, err := freezeJSONValue(v) + if err != nil { + return nil, fmt.Errorf("provider metadata %q: %w", k, err) + } + out[k] = raw + } + return out, nil +} + +func (m ProviderMetadata) SDK() (map[string]any, error) { + if m == nil { + return nil, nil + } + out := make(map[string]any, len(m)) + for k, raw := range m { + v, err := decodeJSONValue(raw) + if err != nil { + return nil, fmt.Errorf("provider metadata %q: %w", k, err) + } + out[k] = v + } + return out, nil +} + +func FreezeCacheControl(c *sdk.CacheControl) *CacheControl { + if c == nil { + return nil + } + return &CacheControl{Type: c.Type, TTL: c.TTL} +} + +func (c *CacheControl) SDK() *sdk.CacheControl { + if c == nil { + return nil + } + return &sdk.CacheControl{Type: c.Type, TTL: c.TTL} +} + +func FreezeToolDefinition(def sdk.ToolDefinition) (ToolDefinition, error) { + params, err := freezeRawJSON(def.Parameters) + if err != nil { + return ToolDefinition{}, fmt.Errorf("tool definition parameters: %w", err) + } + return ToolDefinition{ + Name: def.Name, + Description: def.Description, + Parameters: params, + CacheControl: FreezeCacheControl(def.CacheControl), + }, nil +} + +func (d ToolDefinition) SDK() sdk.ToolDefinition { + return sdk.ToolDefinition{ + Name: d.Name, + Description: d.Description, + Parameters: d.Parameters.RawMessage(), + CacheControl: d.CacheControl.SDK(), + } +} + +func FreezeResponseFormat(f *sdk.ResponseFormat) (*ResponseFormat, error) { + if f == nil { + return nil, nil + } + out := &ResponseFormat{Type: ResponseFormatType(f.Type)} + if f.JSONSchema != nil { + raw, err := json.Marshal(f.JSONSchema) + if err != nil { + return nil, err + } + out.JSONSchema, err = freezeRawJSON(raw) + if err != nil { + return nil, err + } + } + return out, nil +} + +func (f *ResponseFormat) SDK() (*sdk.ResponseFormat, error) { + if f == nil { + return nil, nil + } + out := &sdk.ResponseFormat{Type: sdk.ResponseFormatType(f.Type)} + if !f.JSONSchema.IsZero() { + var schema jsonschema.Schema + if err := json.Unmarshal(f.JSONSchema.Bytes(), &schema); err != nil { + return nil, err + } + out.JSONSchema = &schema + } + return out, nil +} + +func FreezeToolChoice(choice sdk.ToolChoice) ToolChoice { + return ToolChoice{Mode: ToolChoiceMode(choice.Mode), Tool: choice.Tool} +} + +func (c ToolChoice) SDK() sdk.ToolChoice { + return sdk.ToolChoice{Mode: sdk.ToolChoiceMode(c.Mode), Tool: c.Tool} +} + +func FreezeMessagePart(p sdk.MessagePart) (MessagePart, error) { + switch part := p.(type) { + case sdk.TextPart: + meta, err := FreezeProviderMetadata(part.ProviderMetadata) + if err != nil { + return MessagePart{}, err + } + return MessagePart{Type: MessagePartTypeText, Text: part.Text, CacheControl: FreezeCacheControl(part.CacheControl), ProviderMetadata: meta}, nil + case *sdk.TextPart: + if part == nil { + return MessagePart{}, fmt.Errorf("nil *sdk.TextPart") + } + return FreezeMessagePart(*part) + case sdk.ReasoningPart: + meta, err := FreezeProviderMetadata(part.ProviderMetadata) + if err != nil { + return MessagePart{}, err + } + return MessagePart{Type: MessagePartTypeReasoning, ID: part.ID, Text: part.Text, Format: ReasoningFormat(part.Format), Model: part.Model, ProviderMetadata: meta}, nil + case *sdk.ReasoningPart: + if part == nil { + return MessagePart{}, fmt.Errorf("nil *sdk.ReasoningPart") + } + return FreezeMessagePart(*part) + case sdk.ImagePart: + return MessagePart{Type: MessagePartTypeImage, Image: part.Image, MediaType: part.MediaType, CacheControl: FreezeCacheControl(part.CacheControl)}, nil + case *sdk.ImagePart: + if part == nil { + return MessagePart{}, fmt.Errorf("nil *sdk.ImagePart") + } + return FreezeMessagePart(*part) + case sdk.FilePart: + return MessagePart{Type: MessagePartTypeFile, Data: part.Data, MediaType: part.MediaType, Filename: part.Filename, CacheControl: FreezeCacheControl(part.CacheControl)}, nil + case *sdk.FilePart: + if part == nil { + return MessagePart{}, fmt.Errorf("nil *sdk.FilePart") + } + return FreezeMessagePart(*part) + case sdk.ToolCallPart: + input, err := FreezeToolCallInput(part.Input) + if err != nil { + return MessagePart{}, err + } + meta, err := FreezeProviderMetadata(part.ProviderMetadata) + if err != nil { + return MessagePart{}, err + } + return MessagePart{Type: MessagePartTypeToolCall, ToolCallID: part.ToolCallID, ToolName: part.ToolName, Input: input, CacheControl: FreezeCacheControl(part.CacheControl), ProviderMetadata: meta}, nil + case *sdk.ToolCallPart: + if part == nil { + return MessagePart{}, fmt.Errorf("nil *sdk.ToolCallPart") + } + return FreezeMessagePart(*part) + case sdk.ToolResultPart: + result, err := freezeJSONValue(part.Result) + if err != nil { + return MessagePart{}, err + } + return MessagePart{Type: MessagePartTypeToolResult, ToolCallID: part.ToolCallID, ToolName: part.ToolName, Result: result, IsError: part.IsError, CacheControl: FreezeCacheControl(part.CacheControl)}, nil + case *sdk.ToolResultPart: + if part == nil { + return MessagePart{}, fmt.Errorf("nil *sdk.ToolResultPart") + } + return FreezeMessagePart(*part) + default: + return MessagePart{}, fmt.Errorf("unsupported sdk.MessagePart %T", p) + } +} + +//nolint:gocritic // hugeParam: MessagePart is an agent-owned value DTO converted back to SDK at the boundary. +func (p MessagePart) SDK() (sdk.MessagePart, error) { + switch p.Type { + case MessagePartTypeText: + meta, err := p.ProviderMetadata.SDK() + if err != nil { + return nil, err + } + return sdk.TextPart{Text: p.Text, CacheControl: p.CacheControl.SDK(), ProviderMetadata: meta}, nil + case MessagePartTypeReasoning: + meta, err := p.ProviderMetadata.SDK() + if err != nil { + return nil, err + } + return sdk.ReasoningPart{ID: p.ID, Text: p.Text, Format: sdk.ReasoningFormat(p.Format), Model: p.Model, ProviderMetadata: meta}, nil + case MessagePartTypeImage: + return sdk.ImagePart{Image: p.Image, MediaType: p.MediaType, CacheControl: p.CacheControl.SDK()}, nil + case MessagePartTypeFile: + return sdk.FilePart{Data: p.Data, MediaType: p.MediaType, Filename: p.Filename, CacheControl: p.CacheControl.SDK()}, nil + case MessagePartTypeToolCall: + meta, err := p.ProviderMetadata.SDK() + if err != nil { + return nil, err + } + return sdk.ToolCallPart{ToolCallID: p.ToolCallID, ToolName: p.ToolName, Input: p.Input.RawMessage(), CacheControl: p.CacheControl.SDK(), ProviderMetadata: meta}, nil + case MessagePartTypeToolResult: + return sdk.ToolResultPart{ToolCallID: p.ToolCallID, ToolName: p.ToolName, Result: p.Result.RawMessage(), IsError: p.IsError, CacheControl: p.CacheControl.SDK()}, nil + default: + return nil, fmt.Errorf("unknown message part type %q", p.Type) + } +} + +func FreezeMessage(m sdk.Message) (Message, error) { + parts := make([]MessagePart, len(m.Content)) + for i, p := range m.Content { + frozen, err := FreezeMessagePart(p) + if err != nil { + return Message{}, fmt.Errorf("message part %d: %w", i, err) + } + parts[i] = frozen + } + var usage *Usage + if m.Usage != nil { + u := UsageFromSDK(*m.Usage) + usage = &u + } + return Message{Role: MessageRole(m.Role), Content: parts, Usage: usage}, nil +} + +func (m Message) SDK() (sdk.Message, error) { + parts := make([]sdk.MessagePart, len(m.Content)) + for i := range m.Content { + part, err := m.Content[i].SDK() + if err != nil { + return sdk.Message{}, fmt.Errorf("message part %d: %w", i, err) + } + parts[i] = part + } + var usage *sdk.Usage + if m.Usage != nil { + u := m.Usage.SDK() + usage = &u + } + return sdk.Message{Role: sdk.MessageRole(m.Role), Content: parts, Usage: usage}, nil +} + +//nolint:gocritic // hugeParam: freezes a caller-owned SDK Request value into an agent-owned protocol value. +func FreezeModelRequest(req sdk.Request) (ModelRequest, error) { + messages := make([]Message, len(req.Messages)) + for i, m := range req.Messages { + msg, err := FreezeMessage(m) + if err != nil { + return ModelRequest{}, fmt.Errorf("message %d: %w", i, err) + } + messages[i] = msg + } + tools := make([]ToolDefinition, len(req.Tools)) + for i, t := range req.Tools { + tool, err := FreezeToolDefinition(t) + if err != nil { + return ModelRequest{}, fmt.Errorf("tool %d: %w", i, err) + } + tools[i] = tool + } + format, err := FreezeResponseFormat(req.ResponseFormat) + if err != nil { + return ModelRequest{}, fmt.Errorf("response format: %w", err) + } + options := make(map[string]CanonicalJSON, len(req.ProviderOptions)) + if req.ProviderOptions != nil { + for k, v := range req.ProviderOptions { + frozen, err := freezeRawJSON(v) + if err != nil { + return ModelRequest{}, fmt.Errorf("provider option %q: %w", k, err) + } + options[k] = frozen + } + } else { + options = nil + } + return ModelRequest{ + Model: req.Model, + System: req.System, + Messages: messages, + Tools: tools, + ToolChoice: FreezeToolChoice(req.ToolChoice), + ResponseFormat: format, + Temperature: clonePtr(req.Temperature), + TopP: clonePtr(req.TopP), + MaxTokens: clonePtr(req.MaxTokens), + StopSequences: append([]string(nil), req.StopSequences...), + FrequencyPenalty: clonePtr(req.FrequencyPenalty), + PresencePenalty: clonePtr(req.PresencePenalty), + Seed: clonePtr(req.Seed), + ReasoningEffort: clonePtr(req.ReasoningEffort), + ReasoningSummary: clonePtr(req.ReasoningSummary), + PromptCacheKey: clonePtr(req.PromptCacheKey), + ProviderOptions: options, + }, nil +} + +//nolint:gocritic // hugeParam: ModelRequest is the persisted value DTO; SDK returns a detached SDK Request. +func (r ModelRequest) SDK() (sdk.Request, error) { + messages := make([]sdk.Message, len(r.Messages)) + for i, m := range r.Messages { + msg, err := m.SDK() + if err != nil { + return sdk.Request{}, fmt.Errorf("message %d: %w", i, err) + } + messages[i] = msg + } + tools := make([]sdk.ToolDefinition, len(r.Tools)) + for i, t := range r.Tools { + tools[i] = t.SDK() + } + format, err := r.ResponseFormat.SDK() + if err != nil { + return sdk.Request{}, fmt.Errorf("response format: %w", err) + } + options := make(map[string]json.RawMessage, len(r.ProviderOptions)) + if r.ProviderOptions != nil { + for k, v := range r.ProviderOptions { + options[k] = v.RawMessage() + } + } else { + options = nil + } + return sdk.Request{ + Model: r.Model, + System: r.System, + Messages: messages, + Tools: tools, + ToolChoice: r.ToolChoice.SDK(), + ResponseFormat: format, + Temperature: clonePtr(r.Temperature), + TopP: clonePtr(r.TopP), + MaxTokens: clonePtr(r.MaxTokens), + StopSequences: append([]string(nil), r.StopSequences...), + FrequencyPenalty: clonePtr(r.FrequencyPenalty), + PresencePenalty: clonePtr(r.PresencePenalty), + Seed: clonePtr(r.Seed), + ReasoningEffort: clonePtr(r.ReasoningEffort), + ReasoningSummary: clonePtr(r.ReasoningSummary), + PromptCacheKey: clonePtr(r.PromptCacheKey), + ProviderOptions: options, + }, nil +} + +//nolint:gocritic // hugeParam: SDK Usage is copied into an agent-owned Usage value. +func UsageFromSDK(u sdk.Usage) Usage { + return Usage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + TotalTokens: u.TotalTokens, + ReasoningTokens: u.ReasoningTokens, + CachedInputTokens: u.CachedInputTokens, + InputTokenDetails: InputTokenDetail{ + NoCacheTokens: u.InputTokenDetails.NoCacheTokens, + CacheReadTokens: u.InputTokenDetails.CacheReadTokens, + CacheWriteTokens: u.InputTokenDetails.CacheWriteTokens, + CacheWrite5mTokens: u.InputTokenDetails.CacheWrite5mTokens, + CacheWrite1hTokens: u.InputTokenDetails.CacheWrite1hTokens, + }, + OutputTokenDetails: OutputTokenDetail{ + TextTokens: u.OutputTokenDetails.TextTokens, + ReasoningTokens: u.OutputTokenDetails.ReasoningTokens, + }, + } +} + +//nolint:gocritic // hugeParam: Usage conversion is pure and returns a detached SDK value. +func (u Usage) SDK() sdk.Usage { + return sdk.Usage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + TotalTokens: u.TotalTokens, + ReasoningTokens: u.ReasoningTokens, + CachedInputTokens: u.CachedInputTokens, + InputTokenDetails: sdk.InputTokenDetail{ + NoCacheTokens: u.InputTokenDetails.NoCacheTokens, + CacheReadTokens: u.InputTokenDetails.CacheReadTokens, + CacheWriteTokens: u.InputTokenDetails.CacheWriteTokens, + CacheWrite5mTokens: u.InputTokenDetails.CacheWrite5mTokens, + CacheWrite1hTokens: u.InputTokenDetails.CacheWrite1hTokens, + }, + OutputTokenDetails: sdk.OutputTokenDetail{ + TextTokens: u.OutputTokenDetails.TextTokens, + ReasoningTokens: u.OutputTokenDetails.ReasoningTokens, + }, + } +} + +func FreezeReasoningPart(p sdk.ReasoningPart) (ReasoningPart, error) { + meta, err := FreezeProviderMetadata(p.ProviderMetadata) + if err != nil { + return ReasoningPart{}, err + } + return ReasoningPart{ID: p.ID, Text: p.Text, Format: ReasoningFormat(p.Format), Model: p.Model, ProviderMetadata: meta}, nil +} + +func (p ReasoningPart) SDK() (sdk.ReasoningPart, error) { + meta, err := p.ProviderMetadata.SDK() + if err != nil { + return sdk.ReasoningPart{}, err + } + return sdk.ReasoningPart{ID: p.ID, Text: p.Text, Format: sdk.ReasoningFormat(p.Format), Model: p.Model, ProviderMetadata: meta}, nil +} + +func FreezeSource(s sdk.Source) (Source, error) { + meta, err := FreezeProviderMetadata(s.ProviderMetadata) + if err != nil { + return Source{}, err + } + return Source{SourceType: s.SourceType, ID: s.ID, URL: s.URL, Title: s.Title, ProviderMetadata: meta}, nil +} + +func (s Source) SDK() (sdk.Source, error) { + meta, err := s.ProviderMetadata.SDK() + if err != nil { + return sdk.Source{}, err + } + return sdk.Source{SourceType: s.SourceType, ID: s.ID, URL: s.URL, Title: s.Title, ProviderMetadata: meta}, nil +} + +func FreezeGeneratedFile(f sdk.GeneratedFile) GeneratedFile { + return GeneratedFile{Data: f.Data, MediaType: f.MediaType} +} + +func (f GeneratedFile) SDK() sdk.GeneratedFile { + return sdk.GeneratedFile{Data: f.Data, MediaType: f.MediaType} +} + +// FreezeToolCallInput converts an SDK/model-provided tool input into the +// persisted canonical value used by ModelToolCall and ToolCallBinding. +// Syntactically invalid JSON text with valid UTF-8 is preserved as a JSON +// string so Loop can settle it as a known invalid_arguments result without +// losing the text; invalid UTF-8 is rejected. +func FreezeToolCallInput(input any) (CanonicalJSON, error) { + args, err := canonicalToolArguments(input) + if err == nil { + return args, nil + } + switch x := input.(type) { + case string: + if !utf8.ValidString(x) { + return CanonicalJSON{}, fmt.Errorf("tool call input is not valid UTF-8") + } + return rawToolArguments(x), nil + case json.RawMessage: + if !utf8.Valid(x) { + return CanonicalJSON{}, fmt.Errorf("tool call input is not valid UTF-8") + } + return rawToolArguments(x), nil + default: + return CanonicalJSON{}, err + } +} + +func FreezeModelToolCall(c sdk.ToolCall) (ModelToolCall, error) { + input, err := FreezeToolCallInput(c.Input) + if err != nil { + return ModelToolCall{}, fmt.Errorf("tool call input: %w", err) + } + meta, err := FreezeProviderMetadata(c.ProviderMetadata) + if err != nil { + return ModelToolCall{}, err + } + return ModelToolCall{ToolCallID: c.ToolCallID, ToolName: c.ToolName, Input: input, ProviderMetadata: meta}, nil +} + +func (c ModelToolCall) SDK() (sdk.ToolCall, error) { + meta, err := c.ProviderMetadata.SDK() + if err != nil { + return sdk.ToolCall{}, err + } + return sdk.ToolCall{ToolCallID: c.ToolCallID, ToolName: c.ToolName, Input: c.Input.RawMessage(), ProviderMetadata: meta}, nil +} + +func FreezeResponseMetadata(r *sdk.ResponseMetadata) *ResponseMetadata { + if r == nil { + return nil + } + out := &ResponseMetadata{ID: r.ID, ModelID: r.ModelID} + if !r.Timestamp.IsZero() { + out.Timestamp = r.Timestamp.UTC().Format(time.RFC3339Nano) + } + if r.Headers != nil { + out.Headers = make(map[string]string, len(r.Headers)) + for k, v := range r.Headers { + out.Headers[k] = v + } + } + return out +} + +func (r *ResponseMetadata) SDK() (sdk.ResponseMetadata, error) { + if r == nil { + return sdk.ResponseMetadata{}, nil + } + out := sdk.ResponseMetadata{ID: r.ID, ModelID: r.ModelID} + if r.Timestamp != "" { + t, err := time.Parse(time.RFC3339Nano, r.Timestamp) + if err != nil { + return sdk.ResponseMetadata{}, err + } + out.Timestamp = t + } + if r.Headers != nil { + out.Headers = make(map[string]string, len(r.Headers)) + for k, v := range r.Headers { + out.Headers[k] = v + } + } + return out, nil +} + +//nolint:gocritic // hugeParam: freezes a caller-owned SDK ModelResult value into an agent-owned protocol value. +func FreezeModelResult(r sdk.ModelResult) (ModelResult, error) { + reasoning := make([]ReasoningPart, len(r.ReasoningParts)) + for i, p := range r.ReasoningParts { + part, err := FreezeReasoningPart(p) + if err != nil { + return ModelResult{}, fmt.Errorf("reasoning part %d: %w", i, err) + } + reasoning[i] = part + } + textMeta, err := FreezeProviderMetadata(r.TextProviderMetadata) + if err != nil { + return ModelResult{}, fmt.Errorf("text provider metadata: %w", err) + } + sources := make([]Source, len(r.Sources)) + for i, s := range r.Sources { + source, err := FreezeSource(s) + if err != nil { + return ModelResult{}, fmt.Errorf("source %d: %w", i, err) + } + sources[i] = source + } + files := make([]GeneratedFile, len(r.Files)) + for i, f := range r.Files { + files[i] = FreezeGeneratedFile(f) + } + calls := make([]ModelToolCall, len(r.ToolCalls)) + for i, c := range r.ToolCalls { + call, err := FreezeModelToolCall(c) + if err != nil { + return ModelResult{}, fmt.Errorf("tool call %d: %w", i, err) + } + calls[i] = call + } + return ModelResult{ + Text: r.Text, + Reasoning: r.Reasoning, + ReasoningParts: reasoning, + TextProviderMetadata: textMeta, + FinishReason: FinishReason(r.FinishReason), + RawFinishReason: r.RawFinishReason, + Usage: UsageFromSDK(r.Usage), + Sources: sources, + Files: files, + ToolCalls: calls, + Response: FreezeResponseMetadata(r.Response), + }, nil +} + +//nolint:gocritic // hugeParam: ModelResult is the persisted value DTO; SDK returns a detached SDK result. +func (r ModelResult) SDK() (sdk.ModelResult, error) { + reasoning := make([]sdk.ReasoningPart, len(r.ReasoningParts)) + for i, p := range r.ReasoningParts { + part, err := p.SDK() + if err != nil { + return sdk.ModelResult{}, fmt.Errorf("reasoning part %d: %w", i, err) + } + reasoning[i] = part + } + textMeta, err := r.TextProviderMetadata.SDK() + if err != nil { + return sdk.ModelResult{}, fmt.Errorf("text provider metadata: %w", err) + } + sources := make([]sdk.Source, len(r.Sources)) + for i, s := range r.Sources { + source, err := s.SDK() + if err != nil { + return sdk.ModelResult{}, fmt.Errorf("source %d: %w", i, err) + } + sources[i] = source + } + files := make([]sdk.GeneratedFile, len(r.Files)) + for i, f := range r.Files { + files[i] = f.SDK() + } + calls := make([]sdk.ToolCall, len(r.ToolCalls)) + for i, c := range r.ToolCalls { + call, err := c.SDK() + if err != nil { + return sdk.ModelResult{}, fmt.Errorf("tool call %d: %w", i, err) + } + calls[i] = call + } + response, err := r.Response.SDK() + if err != nil { + return sdk.ModelResult{}, fmt.Errorf("response metadata: %w", err) + } + var responsePtr *sdk.ResponseMetadata + if r.Response != nil { + responsePtr = &response + } + return sdk.ModelResult{ + Text: r.Text, + Reasoning: r.Reasoning, + ReasoningParts: reasoning, + TextProviderMetadata: textMeta, + FinishReason: sdk.FinishReason(r.FinishReason), + RawFinishReason: r.RawFinishReason, + Usage: r.Usage.SDK(), + Sources: sources, + Files: files, + ToolCalls: calls, + Response: responsePtr, + }, nil +} diff --git a/agent/run/next.go b/agent/run/next.go new file mode 100644 index 0000000..2677e46 --- /dev/null +++ b/agent/run/next.go @@ -0,0 +1,165 @@ +package run + +import "github.com/felinics/twilight/agent/session" + +// Effect is the at-most-one pending action Machine.Next derives from the +// current state (RUN-MCH-4). Effects are never persisted; the Loop re-derives +// them after every Load. +type Effect interface{ effect() } + +type NeedModelRequest struct { + Hint PlanningHint +} + +func (NeedModelRequest) effect() {} + +type StartModelCall struct { + StepID StepID +} + +func (StartModelCall) effect() {} + +// WithdrawPrepared asks the Loop to commit WithdrawPreparedStep: inputs were +// accepted after this step was Prepared, so its frozen request is incomplete +// and the Run should replan. +type WithdrawPrepared struct { + StepID StepID +} + +func (WithdrawPrepared) effect() {} + +type StartToolCalls struct { + StepID StepID + CallIDs []CallID +} + +func (StartToolCalls) effect() {} + +// Idle means the Run is still active and Next has no executable effect. +// Application inspects MachineState with WaitingCalls, ExecutingCalls, and +// NeedsRecovery. Loop does not interpret those queries. +type Idle struct{} + +func (Idle) effect() {} + +// WaitingCalls returns the outstanding ResponseRequests on the current ToolStep. +// Application uses this after Loop returns LoopWaiting. The result is detached. +func WaitingCalls(s MachineState) []ResponseRequest { + ts, ok := s.Current.(ToolStep) + if !ok { + return nil + } + var out []ResponseRequest + for _, c := range ts.Calls { + if c.Status != ToolWaiting || c.Waiting == nil { + continue + } + cloned := cloneResponseRequest(c.Waiting) + if cloned != nil { + out = append(out, *cloned) + } + } + return out +} + +// ExecutingCalls returns CallIDs still Executing on the current ToolStep. +func ExecutingCalls(s MachineState) []CallID { + ts, ok := s.Current.(ToolStep) + if !ok { + return nil + } + var out []CallID + for _, c := range ts.Calls { + if c.Status == ToolExecuting { + out = append(out, c.CallID) + } + } + return out +} + +// NeedsRecovery reports that an execution is in flight and this process has +// no Start effect for it: a ModelStep is Executing, or a ToolStep has +// Executing calls and no Pending calls. +func NeedsRecovery(s MachineState) bool { + switch cur := s.Current.(type) { + case ModelStep: + return cur.Status == ModelExecuting + case ToolStep: + pending := false + executing := false + for _, c := range cur.Calls { + switch c.Status { + case ToolPending: + pending = true + case ToolExecuting: + executing = true + } + } + return executing && !pending + default: + return false + } +} + +// PlanningHint is what the Loop hands the application RequestPlanner: the Run +// boundary facts only. Conversation content (previous assistant output, tool +// results) is read from the Session by the planner itself. +type PlanningHint struct { + Session session.SessionID // filled by the Loop; Next does not know it + Owner OwnerID + RunID RunID + SourceStep StepID + Inputs []AgentInput +} + +// Next derives the pending effect from the current state (RUN-MCH-4). +// Terminal states return ErrRunTerminal; callers check Status first. +// +//nolint:gocritic // hugeParam: Next is a pure value-state interpreter and must not mutate MachineState. +func Next(s MachineState) (Effect, error) { + if s.Status.Terminal() { + return nil, ErrRunTerminal + } + switch cur := s.Current.(type) { + case Open: + var source StepID + if s.LastToolStep != nil { + source = s.LastToolStep.RefValue.ID + } + return NeedModelRequest{Hint: PlanningHint{ + Owner: s.Owner, + RunID: s.RunID, + SourceStep: source, + Inputs: append([]AgentInput(nil), s.PendingInputs...), + }}, nil + case ModelStep: + if cur.Status == ModelPrepared { + if len(s.PendingInputs) > 0 { + return WithdrawPrepared{StepID: cur.RefValue.ID}, nil + } + return StartModelCall{StepID: cur.RefValue.ID}, nil + } + return Idle{}, nil + case ToolStep: + var pending []CallID + live := false + for _, c := range cur.Calls { + switch c.Status { + case ToolPending: + pending = append(pending, c.CallID) + live = true + case ToolWaiting, ToolExecuting: + live = true + } + } + if len(pending) > 0 { + return StartToolCalls{StepID: cur.RefValue.ID, CallIDs: pending}, nil + } + if live { + return Idle{}, nil + } + return nil, rejectionf("next: tool step %q has no live calls but was not closed", cur.RefValue.ID) + default: + return nil, rejectionf("next: unknown current variant %T", s.Current) + } +} diff --git a/agent/run/protocol.go b/agent/run/protocol.go new file mode 100644 index 0000000..0144802 --- /dev/null +++ b/agent/run/protocol.go @@ -0,0 +1,304 @@ +package run + +import ( + "fmt" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/session" +) + +// SchemaVersion1 is the current pre-release wire schema. Its canonical +// encoding and Evolve folding semantics may still change before publication. +// Once a schema is published, its encoding and folding semantics are frozen. +const SchemaVersion1 uint16 = 1 + +// CommandEnvelope carries one command with its protocol identity. Commands +// are not persisted: ID is the CommitID of the SessionCommit the command +// produces, and replay is told from conflict by the Writer's row fingerprint +// (RUN-WIR-2, EXT-WRT-2). +type CommandEnvelope struct { + SchemaVersion uint16 `json:"schemaVersion"` + Type string `json:"type"` + SessionID session.SessionID `json:"sessionId"` + RunID RunID `json:"runId"` + ID CommandID `json:"id"` + Command AgentCommand `json:"command"` +} + +// encodeEnvelopeBody is the digest input for a command: schema version, type +// discriminator and canonical command bytes. +func encodeEnvelopeBody(schemaVersion uint16, typ string, body any) ([]byte, error) { + return es.EncodeTypedPayload(schemaVersion, typ, body) +} + +// Protocol is the digest, decode, Decide, Evolve, and envelope operations for +// one SchemaVersion. ProtocolFor binds the functions once from the Run header; +// subsequent calls do not take a version argument (RUN-CMT-7). +type Protocol struct { + version uint16 + + digestRequest func(ModelRequest) (Digest, error) + digestToolDefinition func(ToolDefinition) (Digest, error) + digestToolSpec func(ToolSpec) (Digest, error) + digestToolSpecs func([]ToolSpec) (Digest, error) + digestModelStepBinding func(ModelRef, Digest, Digest) (Digest, error) + digestToolResponseDecision func(ResponseKind, ResponseDecision, string) (Digest, error) + digestToolResponsePayload func(CanonicalJSON) (Digest, error) + digestModelResult func(ModelResult) (Digest, error) + digestToolOutput func(CanonicalJSON) (Digest, error) + buildCreateGroup func(NewRun, []AgentInput) ([]Fact, error) + decodeCommand func(string, []byte) (AgentCommand, error) + decodeFact func(string, []byte) (Fact, error) + decide func(MachineState, AgentCommand) ([]Fact, error) + evolve func(MachineState, Fact) (MachineState, error) + encodeMachineState func(*MachineState) ([]byte, error) + decodeMachineState func([]byte) (MachineState, error) +} + +// ProtocolV1 is the SchemaVersion1 binding. New Runs are created with this +// protocol; every later operation on a Run binds through +// ProtocolFor(header.SchemaVersion) or RuntimeSnapshot.Protocol() (RUN-CMT-7). +// There are no package-level functions that implicitly select a version. +func ProtocolV1() Protocol { return protocolV1 } + +var protocolV1 = Protocol{ + version: SchemaVersion1, + digestRequest: digestRequestV1, + digestToolDefinition: digestToolDefinitionV1, + digestToolSpec: digestToolSpecV1, + digestToolSpecs: digestToolSpecsV1, + digestModelStepBinding: digestModelStepBindingV1, + digestToolResponseDecision: digestToolResponseDecisionV1, + digestToolResponsePayload: digestToolResponsePayloadV1, + digestModelResult: digestModelResultV1, + digestToolOutput: digestToolOutputV1, + buildCreateGroup: buildCreateGroupV1, + decodeCommand: decodeCommandVariantV1, + decodeFact: decodeFactVariantV1, + decide: decideV1, + evolve: evolveV1, + encodeMachineState: encodeMachineStateV1, + decodeMachineState: decodeMachineStateV1, +} + +// ProtocolFor binds the protocol functions for a persisted schema version. +// Call it at the Run header, envelope, or event boundary; do not thread the +// version number through digest, Decide, or Evolve. +func ProtocolFor(schemaVersion uint16) (Protocol, error) { + switch schemaVersion { + case SchemaVersion1: + return protocolV1, nil + default: + return Protocol{}, unsupportedSchemaVersion(schemaVersion) + } +} + +func (p Protocol) ready() error { + if p.version == 0 { + return fmt.Errorf("agent: uninitialized protocol") + } + return nil +} + +func (p Protocol) Version() uint16 { return p.version } + +func (p Protocol) DigestRequest(req ModelRequest) (Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ModelRequest value. + if err := p.ready(); err != nil { + return "", err + } + return p.digestRequest(req) +} + +func (p Protocol) DigestToolDefinition(def ToolDefinition) (Digest, error) { + if err := p.ready(); err != nil { + return "", err + } + return p.digestToolDefinition(def) +} + +func (p Protocol) DigestToolSpec(spec ToolSpec) (Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ToolSpec value. + if err := p.ready(); err != nil { + return "", err + } + return p.digestToolSpec(spec) +} + +func (p Protocol) DigestToolSpecs(specs []ToolSpec) (Digest, error) { + if err := p.ready(); err != nil { + return "", err + } + return p.digestToolSpecs(specs) +} + +func (p Protocol) DigestModelStepBinding(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) { + if err := p.ready(); err != nil { + return "", err + } + return p.digestModelStepBinding(model, requestDigest, toolsDigest) +} + +func (p Protocol) DigestToolResponseDecision(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { + if err := p.ready(); err != nil { + return "", err + } + return p.digestToolResponseDecision(kind, decision, reason) +} + +func (p Protocol) DigestToolResponsePayload(payload CanonicalJSON) (Digest, error) { + if err := p.ready(); err != nil { + return "", err + } + return p.digestToolResponsePayload(payload) +} + +// DigestModelResult names a frozen model result (ModelStepCompleted.ResultDigest). +func (p Protocol) DigestModelResult(result ModelResult) (Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ModelResult value. + if err := p.ready(); err != nil { + return "", err + } + return p.digestModelResult(result) +} + +// DigestToolOutput names one tool output (ToolCallCompleted.OutputDigest). +func (p Protocol) DigestToolOutput(output CanonicalJSON) (Digest, error) { + if err := p.ready(); err != nil { + return "", err + } + return p.digestToolOutput(output) +} + +// BuildCreateGroup returns the RunCreated and InputAccepted facts that +// establish a Run (RUN-NEW-1). Encoding them as Session events is the module +// implementation's job. +func (p Protocol) BuildCreateGroup(run NewRun, inputs []AgentInput) ([]Fact, error) { + if err := p.ready(); err != nil { + return nil, err + } + if run.SchemaVersion != p.version { + return nil, fmt.Errorf("agent: create group: run schema %d does not match protocol %d", run.SchemaVersion, p.version) + } + return p.buildCreateGroup(run, inputs) +} + +func (p Protocol) EncodeFact(typ string, fact Fact) ([]byte, error) { + if err := p.ready(); err != nil { + return nil, err + } + if typ == "" || typ != factType(fact) { + return nil, fmt.Errorf("agent: encode: type %q does not match fact variant", typ) + } + return encodeEnvelopeBody(p.version, typ, fact) +} + +func (p Protocol) DigestFact(typ string, fact Fact) (Digest, error) { + body, err := p.EncodeFact(typ, fact) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +func (p Protocol) DecodeCommand(typ string, raw []byte) (AgentCommand, error) { + if err := p.ready(); err != nil { + return nil, err + } + return p.decodeCommand(typ, raw) +} + +func (p Protocol) DecodeFact(typ string, raw []byte) (Fact, error) { + if err := p.ready(); err != nil { + return nil, err + } + return p.decodeFact(typ, raw) +} + +func (p Protocol) Decide(s MachineState, c AgentCommand) ([]Fact, error) { //nolint:gocritic // hugeParam: protocol methods stay value-based. + if err := p.ready(); err != nil { + return nil, err + } + return p.decide(s, c) +} + +func (p Protocol) Evolve(s MachineState, f Fact) (MachineState, error) { //nolint:gocritic // hugeParam: protocol methods stay value-based. + if err := p.ready(); err != nil { + return s, err + } + return p.evolve(s, f) +} + +// BuildEnvelope is the sanctioned envelope constructor (RUN-WIR-3). +func (p Protocol) BuildEnvelope(sid session.SessionID, run RunID, id CommandID, cmd AgentCommand) (CommandEnvelope, error) { + typ := commandType(cmd) + if typ == "" { + return CommandEnvelope{}, fmt.Errorf("agent: envelope: unknown command variant %T", cmd) + } + return CommandEnvelope{ + SchemaVersion: p.version, + Type: typ, + SessionID: sid, + RunID: run, + ID: id, + Command: cmd, + }, nil +} + +// FactType returns the local event name of a fact (the part of the EventType +// after twilight/run/). +func FactType(f Fact) string { return factType(f) } + +// EncodeMachineState renders the persisted snapshot bytes of a MachineState +// under this schema. The bytes are canonical: statesEquivalent, the +// InitialStateDigest preimage, and durable snapshot storage all use them. +func (p Protocol) EncodeMachineState(s *MachineState) ([]byte, error) { + if err := p.ready(); err != nil { + return nil, err + } + return p.encodeMachineState(s) +} + +// DecodeMachineState restores a MachineState from bytes produced by +// EncodeMachineState of the same schema, including the Current step. +func (p Protocol) DecodeMachineState(raw []byte) (MachineState, error) { + if err := p.ready(); err != nil { + return MachineState{}, err + } + return p.decodeMachineState(raw) +} + +type toolResponseDecisionDigestBody struct { + Kind ResponseKind `json:"kind"` + Decision ResponseDecision `json:"decision"` + Reason string `json:"reason,omitempty"` +} + +type toolResponsePayloadDigestBody struct { + Payload CanonicalJSON `json:"payload"` +} + +type toolOutputDigestBody struct { + Output CanonicalJSON `json:"output"` +} + +// digestBindingSet covers the full ordered pre-Response call set of one +// ToolStep; it feeds DeriveToolStepID and is carried inside ToolStepOpened. +// It is pinned to SchemaVersion1: the value is persisted in v1 facts, so a +// future schema bump must not change how replayed v1 state folds. +func digestBindingSet(bindings []ToolCallBinding) (Digest, error) { + body, err := encodeEnvelopeBody(SchemaVersion1, "tool_call_bindings", bindings) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// DigestToolCallBinding covers one binding: definition, policy and canonical +// arguments plus the CallID (RUN-MCH-2). Runtime conformance suites use this +// helper to construct the same frozen binding identities as the Loop. +func DigestToolCallBinding(callID CallID, definitionDigest Digest, policy ResponsePolicy, arguments CanonicalJSON) (Digest, error) { + return sha256Digest([]byte(namespacedHash("twilight/tool-call-binding", + string(callID), string(definitionDigest), fmt.Sprintf("%d", policy), arguments.String()))), nil +} + +func digestToolCallBinding(callID CallID, definitionDigest Digest, policy ResponsePolicy, arguments CanonicalJSON) (Digest, error) { + return DigestToolCallBinding(callID, definitionDigest, policy, arguments) +} diff --git a/agent/run/protocol_test.go b/agent/run/protocol_test.go new file mode 100644 index 0000000..20d474a --- /dev/null +++ b/agent/run/protocol_test.go @@ -0,0 +1,46 @@ +package run + +import "testing" + +func TestProtocolForSelectsV1(t *testing.T) { + p, err := ProtocolFor(SchemaVersion1) + if err != nil { + t.Fatal(err) + } + if p.Version() != SchemaVersion1 { + t.Fatalf("version = %d", p.Version()) + } + if p.Version() != ProtocolV1().Version() { + t.Fatal("ProtocolFor(1) did not bind ProtocolV1()") + } + if _, err := ProtocolFor(0); err == nil { + t.Fatal("schema 0 accepted") + } + if _, err := ProtocolFor(2); err == nil { + t.Fatal("schema 2 accepted") + } +} + +func TestRuntimeSnapshotProtocol(t *testing.T) { + snap := RuntimeSnapshot{SchemaVersion: SchemaVersion1} + p, err := snap.Protocol() + if err != nil { + t.Fatal(err) + } + if p.Version() != SchemaVersion1 { + t.Fatalf("version = %d", p.Version()) + } + if _, err := (RuntimeSnapshot{}).Protocol(); err == nil { + t.Fatal("zero snapshot protocol accepted") + } +} + +func TestZeroProtocolRejectsCalls(t *testing.T) { + var p Protocol + if _, err := p.DigestRequest(ModelRequest{}); err == nil { + t.Fatal("zero protocol DigestRequest accepted") + } + if _, err := p.Decide(MachineState{}, CancelRun{}); err == nil { + t.Fatal("zero protocol Decide accepted") + } +} diff --git a/agent/run/protocol_v1.go b/agent/run/protocol_v1.go new file mode 100644 index 0000000..bc6a8e4 --- /dev/null +++ b/agent/run/protocol_v1.go @@ -0,0 +1,129 @@ +package run + +import ( + "errors" + "fmt" +) + +// SchemaVersion1 digest, decode, Decide, and Evolve helpers. ProtocolV1() binds +// these once; replay of a v1 Run must keep using them after later versions exist. + +func digestRequestV1(req ModelRequest) (Digest, error) { + body, err := encodeEnvelopeBody(SchemaVersion1, "model_request", req) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +func digestToolDefinitionV1(def ToolDefinition) (Digest, error) { + body, err := encodeEnvelopeBody(SchemaVersion1, "tool_definition", def) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +func digestToolSpecV1(spec ToolSpec) (Digest, error) { + body, err := encodeEnvelopeBody(SchemaVersion1, "tool_spec", spec) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +func digestToolSpecsV1(specs []ToolSpec) (Digest, error) { + // The fact wire drops an empty tool list (omitempty), so a decoded fact + // carries nil where the command carried []. The preimage must not + // distinguish them: a zero-tool step would otherwise fail its own + // digest guard after one codec round trip. + if len(specs) == 0 { + specs = nil + } + body, err := encodeEnvelopeBody(SchemaVersion1, "tool_specs", specs) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +func digestToolResponseDecisionV1(kind ResponseKind, decision ResponseDecision, reason string) (Digest, error) { + if kind != ResponseApproval && kind != ResponseExternal { + return "", fmt.Errorf("agent: response decision: unsupported kind %q", kind) + } + if decision != ResponseDecisionApproved && decision != ResponseDecisionRejected { + return "", fmt.Errorf("agent: response decision: unsupported decision %q", decision) + } + body, err := encodeEnvelopeBody(SchemaVersion1, "tool_response_decision", toolResponseDecisionDigestBody{ + Kind: kind, Decision: decision, Reason: reason, + }) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +func digestToolResponsePayloadV1(payload CanonicalJSON) (Digest, error) { + body, err := encodeEnvelopeBody(SchemaVersion1, "tool_response_payload", toolResponsePayloadDigestBody{Payload: payload}) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// digestModelResultV1 names a frozen model result; ModelStepCompleted carries +// this digest and the companion carries the content (RUN-WIR-4). +func digestModelResultV1(result ModelResult) (Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ModelResult value. + body, err := encodeEnvelopeBody(SchemaVersion1, "model_result", result) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// digestToolOutputV1 names one tool output; ToolCallCompleted carries it. +func digestToolOutputV1(output CanonicalJSON) (Digest, error) { + if output.IsZero() { + return "", errors.New("agent: tool output: empty output") + } + body, err := encodeEnvelopeBody(SchemaVersion1, "tool_output", toolOutputDigestBody{Output: output}) + if err != nil { + return "", err + } + return sha256Digest(body), nil +} + +// buildCreateGroupV1 produces the facts that establish a Run and queue its +// initial inputs (RUN-NEW-1). It is pure: the Coordinator places these facts +// in the Start commit, the Runtime never sees a Create command. +func buildCreateGroupV1(run NewRun, inputs []AgentInput) ([]Fact, error) { + if err := ValidateNewRun(run); err != nil { + return nil, err + } + facts := make([]Fact, 0, 1+len(inputs)) + facts = append(facts, RunCreated{SchemaVersion: run.SchemaVersion, RunID: run.RunID, Owner: run.Owner, Attempt: run.Attempt, CausationID: run.CausationID}) + seen := make(map[InputID]struct{}, len(inputs)) + for _, in := range inputs { + if in.ID == "" { + return nil, errors.New("agent: create group: input with empty InputID") + } + if _, dup := seen[in.ID]; dup { + return nil, fmt.Errorf("agent: create group: duplicate InputID %q", in.ID) + } + seen[in.ID] = struct{}{} + facts = append(facts, InputAccepted{Input: cloneAgentInput(in)}) + } + return facts, nil +} + +func unsupportedSchemaVersion(schemaVersion uint16) error { + return fmt.Errorf("agent: unsupported schema version %d", schemaVersion) +} + +func digestModelStepBindingV1(model ModelRef, requestDigest, toolsDigest Digest) (Digest, error) { + if model == "" || requestDigest == "" || toolsDigest == "" { + return "", errors.New("agent: model step binding requires model, request digest and tools digest") + } + return sha256Digest([]byte(namespacedHash("twilight/model-step-binding", + string(model), string(requestDigest), string(toolsDigest)))), nil +} diff --git a/agent/run/regression_test.go b/agent/run/regression_test.go new file mode 100644 index 0000000..9de9b2e --- /dev/null +++ b/agent/run/regression_test.go @@ -0,0 +1,84 @@ +package run + +import "testing" + +func TestRegressionZeroBindingsWithToolCallsRejected(t *testing.T) { + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(), nil) + result := modelResultWithCalls("c1") + if _, err := ProtocolV1().Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: nil}); err == nil { + t.Fatal("result with tool calls and no bindings completed the run") + } +} + +func TestRegressionBindingMustMatchModelResult(t *testing.T) { + safe := testToolDef("safe") + danger := testToolDef("danger") + specSafe := makeSpec(t, safe, DirectExecution) + specDanger := makeSpec(t, danger, DirectExecution) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(safe, danger), []ToolSpec{specSafe, specDanger}) + + evil := makeBinding(t, stepID, 0, "c1", specDanger, `{"rm":"-rf"}`) + result := modelResultWithNamedCalls("safe", `{"a":1}`, "c1") + if _, err := ProtocolV1().Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{evil}}); err == nil { + t.Fatal("binding for a tool the model never called was accepted") + } + + tampered := makeBinding(t, stepID, 0, "c1", specSafe, `{"a":999}`) + if _, err := ProtocolV1().Decide(s, SubmitModelResult{StepID: stepID, Result: result, Calls: []ToolCallBinding{tampered}}); err == nil { + t.Fatal("binding with tampered arguments was accepted") + } +} + +func TestRegressionToolStepIDReproducible(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, ApprovalRequired) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + opened := facts[1].(ToolStepOpened) + if DeriveToolStepID(opened.Source, opened.BindingSetDigest) != opened.StepID { + t.Fatal("ToolStepOpened digest does not reproduce its StepID") + } + s = fold(t, s, facts) + ts := s.Current.(ToolStep) + if DeriveToolStepID(ts.Source, ts.RefValue.Digest) != ts.RefValue.ID { + t.Fatal("persisted StepRef.Digest does not reproduce the step ID") + } +} + +func TestRegressionEvolveRejectsIllegalCallState(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + s := newRun(t) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + opened := facts[1].(ToolStepOpened) + s = fold(t, s, facts) + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: opened.StepID, CallID: cid(stepID, 0)})) + + _, err := ProtocolV1().Evolve(s, ToolCallFailed{ + StepID: opened.StepID, + CallID: cid(stepID, 0), + Failure: ToolFailure{Class: FailureExecution}, + Outcome: ToolOutcomeUnknown, + }) + if err == nil { + t.Fatal("Evolve accepted an illegal unknown-outcome class") + } +} + +func TestRegressionCancelReasonFixed(t *testing.T) { + s := newRun(t) + if _, err := ProtocolV1().Decide(s, CancelRun{Reason: RunReason("other")}); err == nil { + t.Fatal("CancelRun accepted a non-cancellation reason") + } + facts := mustDecide(t, s, CancelRun{}) + end, ok := facts[0].(RunEnded).End.(RunStoppedEnd) + if !ok || end.Reason != ReasonCancelled { + t.Fatal("cancel reason not fixed to cancelled") + } +} diff --git a/agent/run/runtest/approval_test.go b/agent/run/runtest/approval_test.go new file mode 100644 index 0000000..eca4788 --- /dev/null +++ b/agent/run/runtest/approval_test.go @@ -0,0 +1,57 @@ +package runtest_test + +import ( + "testing" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/runtest" +) + +func TestApprovalWaitsThenResumes(t *testing.T) { + f := runtest.New(t) + f.Tool("echo", run.ApprovalRequired) + f.Model(runtest.ToolCalls("echo", "c1"), runtest.Text("after")) + f.Run() + f.RequireWaiting(run.ResponseApproval) + f.RequireNotRan("echo") + w := f.Waiting() + if err := f.TryCommit(run.ApproveToolCall{ + StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: "sha256:bad", + }); err == nil { + t.Fatal("approval with bad response digest accepted") + } + f.Approve() + f.RequireCallPending("c1") + f.Run() + f.RequireCompleted("after") + f.RequireRan("echo") +} + +func TestApprovalRejectIsPermissionDenied(t *testing.T) { + f := runtest.New(t) + f.Tool("echo", run.ApprovalRequired) + f.Model(runtest.ToolCalls("echo", "c1")) + f.Run() + f.RequireWaiting(run.ResponseApproval) + f.Reject("no") + f.RequireActive() + f.RequireOpen() + f.RequireFailureClass(run.FailurePermissionDenied) + f.RequireNotRan("echo") +} + +func TestApprovalYieldsAfterDirectExecution(t *testing.T) { + f := runtest.New(t) + f.Tool("ask", run.ApprovalRequired) + f.Tool("work", run.DirectExecution) + f.Model(runtest.Calls(runtest.Call("ask", "cA"), runtest.Call("work", "cB")), runtest.Text("after")) + f.Run() + f.RequireWaiting(run.ResponseApproval) + f.RequireWaitingProvider("cA") + f.RequireRan("work") + f.RequireNotRan("ask") + f.Approve() + f.Run() + f.RequireCompleted("after") + f.RequireRan("ask") +} diff --git a/agent/run/runtest/cancel_test.go b/agent/run/runtest/cancel_test.go new file mode 100644 index 0000000..66ab7fe --- /dev/null +++ b/agent/run/runtest/cancel_test.go @@ -0,0 +1,34 @@ +package runtest_test + +import ( + "testing" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/runtest" +) + +func TestCancelStopsIdleRun(t *testing.T) { + f := runtest.New(t) + f.Cancel() + f.RequireStopped() + f.RequireNoUncertain() + f.Run() + f.RequireStopped() +} + +func TestCancelProjectsExecutingTool(t *testing.T) { + f := runtest.New(t) + f.Tool("echo", run.DirectExecution) + f.ExecutingTool("echo", "c1") + f.Cancel() + f.RequireStopped() + f.RequireUncertainCall("c1") +} + +func TestCancelProjectsExecutingModel(t *testing.T) { + f := runtest.New(t) + f.ExecutingModel() + f.Cancel() + f.RequireStopped() + f.RequireUncertainModel() +} diff --git a/agent/run/runtest/error_test.go b/agent/run/runtest/error_test.go new file mode 100644 index 0000000..945eca4 --- /dev/null +++ b/agent/run/runtest/error_test.go @@ -0,0 +1,31 @@ +package runtest_test + +import ( + "context" + "errors" + "testing" + + "github.com/felinics/twilight/agent/run/runtest" +) + +func TestCatalogResolveErrorLeavesRunActive(t *testing.T) { + missing := errors.New("missing provider") + f := runtest.New(t) + f.ModelResolveError(missing) + f.RunError(missing) + f.RequireActive() + f.RequirePrepared() +} + +func TestContextCancelBeforeRunLeavesRunActive(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + f := runtest.New(t) + f.Model(runtest.Text("resumed")) + f.Context(ctx) + f.RunError(context.Canceled) + f.RequireActive() + f.Context(context.Background()) + f.Run() + f.RequireCompleted("resumed") +} diff --git a/agent/run/runtest/feature.go b/agent/run/runtest/feature.go new file mode 100644 index 0000000..374bb35 --- /dev/null +++ b/agent/run/runtest/feature.go @@ -0,0 +1,522 @@ +// Package runtest drives agent Run features for tests. +// +// A Feature owns one in-process Runtime and, when Run is called, one Loop. +// Tests name protocol features and speak in Tool/Model/Run/RunError/Approve/Require*. +// Digest, envelope, revision, and default claims stay inside the driver. +package runtest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" + "github.com/felinics/twilight/sdk" +) + +const ( + defaultRunID = "run-1" + defaultModel = "m-1" + defaultSession session.SessionID = "s-1" +) + +// nopCompanion writes no conversation content: Feature tests exercise Run +// facts, not the chatlog. +type nopCompanion struct{} + +func (nopCompanion) Version() string { return "runtest/nop" } +func (nopCompanion) Map(run.CompanionRequest) ([]run.ModuleEvent, error) { return nil, nil } + +// newRuntime assembles the Memory Session stack with only the run module and +// creates the Run with its seed input through a Start-like group. +func newRuntime(t testing.TB, inputs ...run.AgentInput) run.Runtime { + t.Helper() + store := session.NewMemoryStore() + registry, err := extension.BuildRegistry(session.ProtocolVersion1, runmod.Module) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := store.Create(ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: defaultSession}); err != nil { + t.Fatal(err) + } + writers := extension.NewWriters(store, registry, extension.Admission{}, session.OpenOptions{}) + rt, err := runmod.NewRuntime(runmod.Config{Writers: writers, Registry: registry, Store: store, Companion: nopCompanion{}}) + if err != nil { + t.Fatal(err) + } + newRun, err := run.BuildNewRun(defaultRunID, "") + if err != nil { + t.Fatal(err) + } + facts, err := run.ProtocolV1().BuildCreateGroup(newRun, inputs) + if err != nil { + t.Fatal(err) + } + group := &extension.SemanticGroup{CommitID: "create/" + defaultRunID} + for _, f := range facts { + group.Events = append(group.Events, extension.TypedEvent{Type: runmod.EventType(f), Value: runmod.Event{RunID: defaultRunID, Fact: f}}) + } + w, err := writers.Writer(ctx, defaultSession) + if err != nil { + t.Fatal(err) + } + res, err := w.Commit(ctx, func(extension.View) (*extension.SemanticGroup, error) { return group, nil }) + if err != nil { + t.Fatal(err) + } + if res.Outcome != extension.CommitApplied { + t.Fatalf("create run: %s %s", res.Outcome, res.Detail) + } + return rt +} + +// Feature is one seeded Run plus the Loop/Runtime used to drive it. +type Feature struct { + t testing.TB + ctx context.Context + runCtx context.Context + runID run.RunID + rt run.Runtime + + model run.ModelRef + results []sdk.ModelResult + specs []run.ToolSpec + defs map[run.ToolRef]sdk.ToolDefinition // provider bodies behind specs; ToolSpec keeps only the digest + tools map[run.ToolRef]*scriptTool + invoker *scriptInvoker + planner *scriptPlanner + loop *loop.Loop + seq int + + modelStepID run.StepID + last loop.LoopResult + resolveErr error +} + +// New creates a Runtime, a Run, and the seed input. Configure tools and +// model results before Run or Executing*. +func New(t testing.TB) *Feature { + t.Helper() + rt := newRuntime(t, run.AgentInput{ID: "seed", Payload: run.MustParseCanonicalJSON(`{"q":"hi"}`)}) + f := &Feature{ + t: t, + ctx: context.Background(), + runCtx: context.Background(), + runID: defaultRunID, + rt: rt, + model: defaultModel, + defs: make(map[run.ToolRef]sdk.ToolDefinition), + tools: make(map[run.ToolRef]*scriptTool), + } + return f +} + +// Tool registers a catalog tool. Default execution echoes the call arguments. +func (f *Feature) Tool(name string, policy run.ResponsePolicy) *Feature { + f.t.Helper() + f.guardConfig() + spec, def := f.mustSpec(name, policy) + f.specs = append(f.specs, spec) + f.defs[spec.Ref] = def + f.tools[spec.Ref] = &scriptTool{ + ref: spec.Ref, + def: def, + policy: policy, + } + return f +} + +// Unknown registers a DirectExecution tool whose Execute returns Unknown. +func (f *Feature) Unknown(name string) *Feature { + f.t.Helper() + f.Tool(name, run.DirectExecution) + f.tools[run.ToolRef(name)].unknown = true + return f +} + +// KnownFailure registers a DirectExecution tool whose Execute returns a known failure. +func (f *Feature) KnownFailure(name, class string) *Feature { + f.t.Helper() + f.Tool(name, run.DirectExecution) + f.tools[run.ToolRef(name)].fail = class + return f +} + +// Model sets the scripted provider results, in Generate order. +func (f *Feature) Model(results ...sdk.ModelResult) *Feature { + f.t.Helper() + f.guardConfig() + f.results = append(f.results, results...) + return f +} + +// ModelResolveError makes the Loop catalog Resolve return err. +func (f *Feature) ModelResolveError(err error) *Feature { + f.t.Helper() + f.guardConfig() + f.resolveErr = err + return f +} + +// Context sets the context passed to the next Loop.Run. Load and Commit +// keep using the Feature's background context. +func (f *Feature) Context(ctx context.Context) *Feature { + f.t.Helper() + f.runCtx = ctx + return f +} + +// Run interprets executable effects through Loop until it yields or finishes. +// A Loop error fails the test; expected errors use RunError. +func (f *Feature) Run() *Feature { + f.t.Helper() + if err := f.drive(); err != nil { + f.t.Fatalf("Run: %v", err) + } + return f +} + +// RunError drives Loop and checks the error with errors.Is. +func (f *Feature) RunError(want error) *Feature { + f.t.Helper() + if want == nil { + f.t.Fatal("RunError: nil want") + } + err := f.drive() + if !errors.Is(err, want) { + f.t.Fatalf("Run error = %v, want %v", err, want) + } + return f +} + +func (f *Feature) drive() error { + f.t.Helper() + f.ensureLoop() + res, err := f.loop.Run(f.runCtx, f.rt, defaultSession, f.runID, nil) + f.last = res + return err +} + +// Waiting returns the current ResponseRequest. Tests that submit a +// malformed ingress command use this with TryCommit. +func (f *Feature) Waiting() run.ResponseRequest { + f.t.Helper() + return f.waiting() +} + +// TryCommit submits cmd and returns the Runtime error. Feature tests use +// this for rejected ingress; happy-path commands go through Approve/Reject. +func (f *Feature) TryCommit(cmd run.AgentCommand) error { + f.t.Helper() + snap := f.load() + proto, err := snap.Protocol() + if err != nil { + f.t.Fatal(err) + } + f.seq++ + cmd = withClaim(run.CommandID(fmt.Sprintf("attempt-%d", f.seq)), cmd) + id := f.commandID(cmd, snap) + env, err := proto.BuildEnvelope(defaultSession, f.runID, id, cmd) + if err != nil { + return err + } + _, err = f.rt.Commit(f.ctx, defaultSession, run.CommitRequest{Base: snap.Position, Command: env}) + return err +} + +// Approve commits ApproveToolCall for the current waiting call. +func (f *Feature) Approve() *Feature { + f.t.Helper() + w := f.waiting() + digest, err := run.ProtocolV1().DigestToolResponseDecision(w.Kind, run.ResponseDecisionApproved, "") + if err != nil { + f.t.Fatal(err) + } + f.commit(run.ApproveToolCall{ + StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, ResponseDigest: digest, + }) + return f +} + +// Reject commits RejectToolCall for the current waiting call. +func (f *Feature) Reject(reason string) *Feature { + f.t.Helper() + w := f.waiting() + digest, err := run.ProtocolV1().DigestToolResponseDecision(w.Kind, run.ResponseDecisionRejected, reason) + if err != nil { + f.t.Fatal(err) + } + f.commit(run.RejectToolCall{ + StepID: w.StepID, CallID: w.CallID, ResponseID: w.ID, + ResponseDigest: digest, Reason: reason, + }) + return f +} + +// Cancel commits CancelRun. +func (f *Feature) Cancel() *Feature { + f.t.Helper() + f.commit(run.CancelRun{}) + return f +} + +// ExecutingModel leaves the Run on an Executing ModelStep (no Loop). +func (f *Feature) ExecutingModel() *Feature { + f.t.Helper() + f.commitPrepare() + f.commit(run.StartModelExecution{StepID: f.modelStepID}) + return f +} + +// callByProvider resolves a provider tool_call_id to the Run's derived CallID +// by scanning every ToolStepOpened committed so far. +func (f *Feature) callByProvider(providerID string) run.CallID { + f.t.Helper() + for _, fact := range f.facts() { + opened, ok := fact.(run.ToolStepOpened) + if !ok { + continue + } + for _, b := range opened.Calls { + if b.ProviderCallID == providerID { + return b.CallID + } + } + } + f.t.Fatalf("no tool call with provider id %q", providerID) + return "" +} + +// ExecutingTool leaves the named tool call Executing (no Loop). callID is the +// provider-side id the scripted model emits. +func (f *Feature) ExecutingTool(name string, callID run.CallID) *Feature { + f.t.Helper() + var spec run.ToolSpec + found := false + for _, candidate := range f.specs { + if candidate.Ref == run.ToolRef(name) { + spec = candidate + found = true + break + } + } + if !found { + f.t.Fatalf("ExecutingTool: tool %q not registered", name) + } + f.ExecutingModel() + providerID := string(callID) + callID = run.DeriveCallID(f.modelStepID, 0) + args := run.MustParseCanonicalJSON(`{"x":1}`) + binding, err := run.DigestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, args) + if err != nil { + f.t.Fatal(err) + } + frozen, err := run.FreezeModelResult(sdk.ModelResult{ + FinishReason: sdk.FinishReasonToolCalls, + Usage: sdk.Usage{TotalTokens: 2}, + ToolCalls: []sdk.ToolCall{{ + ToolCallID: providerID, ToolName: string(spec.Ref), Input: `{"x":1}`, + }}, + }) + if err != nil { + f.t.Fatal(err) + } + res := f.commit(run.SubmitModelResult{ + StepID: f.modelStepID, + Result: frozen, + Calls: []run.ToolCallBinding{{ + CallID: callID, ProviderCallID: providerID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + BindingDigest: binding, Arguments: args, Policy: spec.Policy, + }}, + }) + ts, ok := res.Snapshot.State.Current.(run.ToolStep) + if !ok { + f.t.Fatalf("after model result: %T", res.Snapshot.State.Current) + } + f.commit(run.StartToolCall{StepID: ts.Ref().ID, CallID: callID}) + return f +} + +func (f *Feature) guardConfig() { + f.t.Helper() + if f.loop != nil { + f.t.Fatal("configure Tool/Model/ModelResolveError before Run") + } +} + +func (f *Feature) ensureLoop() { + f.t.Helper() + if f.loop != nil { + return + } + f.invoker = &scriptInvoker{results: f.results} + f.planner = &scriptPlanner{model: f.model, specs: f.specs, defs: f.defs} + tools := make(map[run.ToolRef]loop.ExecutableTool, len(f.tools)) + for ref, tool := range f.tools { + tools[ref] = tool + } + l, err := loop.New(scriptCatalog{invoker: f.invoker, err: f.resolveErr}, scriptToolCatalog{tools}, f.planner, loop.ExecutionPolicy{}, false) + if err != nil { + f.t.Fatal(err) + } + f.loop = l +} + +func (f *Feature) load() run.RuntimeSnapshot { + f.t.Helper() + snap, err := f.rt.Load(f.ctx, defaultSession, f.runID) + if err != nil { + f.t.Fatal(err) + } + return snap +} + +func (f *Feature) state() run.MachineState { + f.t.Helper() + return f.load().State +} + +func (f *Feature) waiting() run.ResponseRequest { + f.t.Helper() + reqs := run.WaitingCalls(f.state()) + if len(reqs) == 0 { + f.t.Fatal("no waiting call") + } + return reqs[0] +} + +func (f *Feature) commit(cmd run.AgentCommand) run.CommitResult { + f.t.Helper() + snap := f.load() + proto, err := snap.Protocol() + if err != nil { + f.t.Fatal(err) + } + f.seq++ + cmd = withClaim(run.CommandID(fmt.Sprintf("attempt-%d", f.seq)), cmd) + id := f.commandID(cmd, snap) + env, err := proto.BuildEnvelope(defaultSession, f.runID, id, cmd) + if err != nil { + f.t.Fatal(err) + } + res, err := f.rt.Commit(f.ctx, defaultSession, run.CommitRequest{ + Base: snap.Position, Command: env, + }) + if err != nil { + f.t.Fatalf("commit %T: %v", cmd, err) + } + return res +} + +func (f *Feature) commandID(cmd run.AgentCommand, snap run.RuntimeSnapshot) run.CommandID { + switch c := cmd.(type) { + case run.AcceptInput: + return run.DeriveInputCommandID(f.runID, c.Input.ID) + case run.ApproveToolCall: + return run.DeriveResponseCommandID(f.runID, c.StepID, c.CallID, c.ResponseID) + case run.RejectToolCall: + return run.DeriveResponseCommandID(f.runID, c.StepID, c.CallID, c.ResponseID) + case run.SubmitToolResponse: + return run.DeriveResponseCommandID(f.runID, c.StepID, c.CallID, c.ResponseID) + case run.PrepareModelRequest: + return run.DeriveModelRequestCommandID(f.runID, snap.Position) + case run.RecoverModelExecution: + return run.DeriveModelRecoveryCommandID(f.runID, c.StepID, c.Claim) + case run.StartModelExecution: + return run.DeriveStartCommandID(f.runID, c.StepID, "", c.Claim) + case run.StartToolCall: + return run.DeriveStartCommandID(f.runID, c.StepID, c.CallID, c.Claim) + default: + f.seq++ + return run.CommandID(fmt.Sprintf("cmd-%d", f.seq)) + } +} + +func (f *Feature) commitPrepare() { + f.t.Helper() + snap := f.load() + req := sdk.Request{Model: string(f.model), Messages: []sdk.Message{sdk.UserMessage("go")}} + for _, spec := range f.specs { + req.Tools = append(req.Tools, f.defs[spec.Ref]) + } + frozen, err := run.FreezeModelRequest(req) + if err != nil { + f.t.Fatal(err) + } + proto, err := snap.Protocol() + if err != nil { + f.t.Fatal(err) + } + reqDigest, err := proto.DigestRequest(frozen) + if err != nil { + f.t.Fatal(err) + } + toolsDigest, err := proto.DigestToolSpecs(f.specs) + if err != nil { + f.t.Fatal(err) + } + binding, err := proto.DigestModelStepBinding(f.model, reqDigest, toolsDigest) + if err != nil { + f.t.Fatal(err) + } + cmdID := run.DeriveModelRequestCommandID(f.runID, snap.Position) + stepID := run.DeriveModelStepID(f.runID, cmdID, binding) + ids := make([]run.InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID + } + f.modelStepID = stepID + f.commit(run.PrepareModelRequest{ + StepID: stepID, Model: f.model, Request: frozen, + RequestDigest: reqDigest, InputIDs: ids, Tools: f.specs, ToolsDigest: toolsDigest, + }) +} + +// mustSpec returns the agent-side spec and the provider definition it digests. +func (f *Feature) mustSpec(name string, policy run.ResponsePolicy) (run.ToolSpec, sdk.ToolDefinition) { + f.t.Helper() + def := sdk.ToolDefinition{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)} + frozen, err := run.FreezeToolDefinition(def) + if err != nil { + f.t.Fatal(err) + } + d, err := run.ProtocolV1().DigestToolDefinition(frozen) + if err != nil { + f.t.Fatal(err) + } + return run.ToolSpec{Ref: run.ToolRef(name), Name: name, DefinitionDigest: d, Policy: policy}, def +} + +func (f *Feature) facts() []run.Fact { + f.t.Helper() + record, err := f.rt.Record(f.ctx, defaultSession, f.runID) + if err != nil { + f.t.Fatal(err) + } + return record.Facts +} + +func withClaim(id run.CommandID, cmd run.AgentCommand) run.AgentCommand { + claim := run.ExecutionClaim("runtest/" + string(id)) + switch c := cmd.(type) { + case run.StartModelExecution: + if c.Claim == "" { + c.Claim = claim + } + return c + case run.StartToolCall: + if c.Claim == "" { + c.Claim = claim + } + return c + default: + return cmd + } +} diff --git a/agent/run/runtest/require.go b/agent/run/runtest/require.go new file mode 100644 index 0000000..82dd053 --- /dev/null +++ b/agent/run/runtest/require.go @@ -0,0 +1,303 @@ +package runtest + +import ( + "errors" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" +) + +// RequireWaiting checks Loop yielded and a call is waiting for kind. +func (f *Feature) RequireWaiting(kind run.ResponseKind) { + f.t.Helper() + if f.last.Disposition != loop.LoopWaiting || f.last.ExecutionRecovery { + f.t.Fatalf("loop = %+v, want Waiting", f.last) + } + w := f.waiting() + if w.Kind != kind { + f.t.Fatalf("waiting kind = %s, want %s", w.Kind, kind) + } + want := run.DeriveResponseID(w.RunID, w.StepID, w.CallID, w.Kind) + if w.ID != want { + f.t.Fatalf("ResponseID = %q, want derived %q", w.ID, want) + } +} + +// RequireWaitingProvider checks the waiting call is the one the model issued +// under providerID. +func (f *Feature) RequireWaitingProvider(providerID string) { + f.t.Helper() + if got, want := f.waiting().CallID, f.callByProvider(providerID); got != want { + f.t.Fatalf("waiting call = %s, want %s (provider %s)", got, want, providerID) + } +} + +// RequireCompleted checks the Run finished and that the last ModelStepCompleted +// names the model result carrying text: the fact keeps only the digest, so the +// check digests the result the scripted invoker actually returned. +func (f *Feature) RequireCompleted(text string) { + f.t.Helper() + s := f.state() + if s.Status != run.RunCompleted || s.Result == nil { + f.t.Fatalf("state = %+v", s) + } + if f.loop != nil && f.last.Disposition != loop.LoopFinished { + f.t.Fatalf("loop = %+v, want Finished", f.last) + } + if f.invoker == nil { + return + } + last, ok := f.invoker.lastResult() + if !ok || last.Text != text { + f.t.Fatalf("last model result = %+v, want text %q", last, text) + } + frozen, err := run.FreezeModelResult(last) + if err != nil { + f.t.Fatal(err) + } + want, err := run.ProtocolV1().DigestModelResult(frozen) + if err != nil { + f.t.Fatal(err) + } + var got run.Digest + for _, fact := range f.facts() { + if c, ok := fact.(run.ModelStepCompleted); ok { + got = c.ResultDigest + } + } + if got != want { + f.t.Fatalf("last ModelStepCompleted.ResultDigest = %s, want digest of %q (%s)", got, text, want) + } +} + +// RequireFailed checks the Run failed with reason. +func (f *Feature) RequireFailed(reason run.RunReason) { + f.t.Helper() + s := f.state() + if s.Status != run.RunFailed || s.Result == nil || s.Result.Reason != reason { + f.t.Fatalf("state = %+v, want failed %s", s, reason) + } +} + +// RequireStopped checks the Run stopped as cancelled. +func (f *Feature) RequireStopped() { + f.t.Helper() + s := f.state() + if s.Status != run.RunStopped || s.Result == nil || s.Result.Reason != run.ReasonCancelled { + f.t.Fatalf("state = %+v, want stopped cancelled", s) + } +} + +// RequireActive checks the Run is still active. +func (f *Feature) RequireActive() { + f.t.Helper() + if f.state().Status != run.RunActive { + f.t.Fatalf("status = %v, want active", f.state().Status) + } +} + +// RequirePrepared checks the current step is a prepared ModelStep. +func (f *Feature) RequirePrepared() { + f.t.Helper() + ms, ok := f.state().Current.(run.ModelStep) + if !ok || ms.Status != run.ModelPrepared { + f.t.Fatalf("current = %+v, want prepared model", f.state().Current) + } +} + +// RequireOpen checks the Run is at the enterable Open interval. +func (f *Feature) RequireOpen() { + f.t.Helper() + if _, ok := f.state().Current.(run.Open); !ok { + f.t.Fatalf("current = %+v, want Open", f.state().Current) + } +} + +// RequireCallPending checks the named call on the current ToolStep is Pending. +func (f *Feature) RequireCallPending(id run.CallID) { + f.t.Helper() + id = f.callByProvider(string(id)) + ts, ok := f.state().Current.(run.ToolStep) + if !ok { + f.t.Fatalf("current = %T, want ToolStep", f.state().Current) + } + for _, call := range ts.Calls { + if call.CallID == id { + if call.Status != run.ToolPending { + f.t.Fatalf("call %s status = %v, want Pending", id, call.Status) + } + return + } + } + f.t.Fatalf("call %s not on current ToolStep", id) +} + +// RequireNotRan checks the tool has not executed. +func (f *Feature) RequireNotRan(name string) { + f.t.Helper() + tool := f.tools[run.ToolRef(name)] + if tool == nil { + f.t.Fatalf("tool %q not registered", name) + } + if tool.ran.Load() != 0 { + f.t.Fatalf("tool %q ran %d times", name, tool.ran.Load()) + } +} + +// RequireRan checks the tool executed at least once. +func (f *Feature) RequireRan(name string) { + f.t.Helper() + tool := f.tools[run.ToolRef(name)] + if tool == nil { + f.t.Fatalf("tool %q not registered", name) + } + if tool.ran.Load() == 0 { + f.t.Fatalf("tool %q never ran", name) + } +} + +// RequireModelCalls checks how many times the scripted invoker was called. +func (f *Feature) RequireModelCalls(n int) { + f.t.Helper() + if f.invoker == nil { + f.t.Fatal("no Loop invoker") + } + if got := int(f.invoker.calls.Load()); got != n { + f.t.Fatalf("model calls = %d, want %d", got, n) + } +} + +// RequireUsage checks accumulated total tokens on the Run result. +func (f *Feature) RequireUsage(total int) { + f.t.Helper() + s := f.state() + if s.Result == nil || s.Result.Usage.TotalTokens != total { + f.t.Fatalf("usage = %+v, want %d", s.Result, total) + } +} + +// RequirePlannerSawTool checks the next Plan was positioned after the ToolStep +// on which callID completed with output. The hint carries only the boundary +// (SourceStep); the completed call's OutputDigest is read from the Run state. +func (f *Feature) RequirePlannerSawTool(callID run.CallID, output string) { + f.t.Helper() + callID = f.callByProvider(string(callID)) + if f.planner == nil || f.planner.lastHint.SourceStep == "" { + f.t.Fatal("planner hint has no SourceStep") + } + s := f.state() + if s.LastToolStep == nil || s.LastToolStep.RefValue.ID != f.planner.lastHint.SourceStep { + f.t.Fatalf("hint SourceStep = %s, LastToolStep = %+v", f.planner.lastHint.SourceStep, s.LastToolStep) + } + want, err := run.ProtocolV1().DigestToolOutput(run.MustParseCanonicalJSON(output)) + if err != nil { + f.t.Fatal(err) + } + for _, call := range s.LastToolStep.Calls { + if call.CallID == callID && call.Status == run.ToolCompleted && call.Result != nil && call.Result.OutputDigest == want { + return + } + } + f.t.Fatalf("LastToolStep = %+v, want completed %s with output %s", s.LastToolStep.Calls, callID, output) +} + +// RequireCallFailed checks a ToolCallFailed fact for this call and outcome. +func (f *Feature) RequireCallFailed(id run.CallID, outcome run.ToolFailureOutcome) { + f.t.Helper() + id = f.callByProvider(string(id)) + for _, fact := range f.facts() { + failed, ok := fact.(run.ToolCallFailed) + if ok && failed.CallID == id && failed.Outcome == outcome { + return + } + } + f.t.Fatalf("no ToolCallFailed for %s with outcome %v", id, outcome) +} + +// RequireFailureClass checks a ToolCallFailed fact with class was committed. +func (f *Feature) RequireFailureClass(class string) { + f.t.Helper() + for _, fact := range f.facts() { + failed, ok := fact.(run.ToolCallFailed) + if ok && failed.Failure.Class == class { + return + } + } + f.t.Fatalf("no ToolCallFailed with class %s", class) +} + +// RequireFailureCall checks the failed RunResult names this call. +func (f *Feature) RequireFailureCall(id run.CallID) { + f.t.Helper() + id = f.callByProvider(string(id)) + s := f.state() + if s.Result == nil || s.Result.Failure == nil || s.Result.Failure.CallID != id { + f.t.Fatalf("failure = %+v, want call %s", s.Result, id) + } +} + +// RequireFactOpened checks ToolStepOpened was committed. +func (f *Feature) RequireFactOpened() { + f.t.Helper() + for _, fact := range f.facts() { + if _, ok := fact.(run.ToolStepOpened); ok { + return + } + } + f.t.Fatal("no ToolStepOpened fact") +} + +// RequireNoUncertain checks Cancel recorded no in-flight effects. +func (f *Feature) RequireNoUncertain() { + f.t.Helper() + s := f.state() + if s.Result == nil { + f.t.Fatal("no result") + } + if len(s.Result.UncertainCalls) != 0 || s.Result.UncertainModel != "" { + f.t.Fatalf("uncertain = %+v", s.Result) + } +} + +// RequireUncertainCall checks Cancel projected this executing tool call. +func (f *Feature) RequireUncertainCall(id run.CallID) { + f.t.Helper() + id = f.callByProvider(string(id)) + s := f.state() + if s.Result == nil { + f.t.Fatal("no result") + } + for _, got := range s.Result.UncertainCalls { + if got == id { + return + } + } + f.t.Fatalf("UncertainCalls = %v, want %s", s.Result.UncertainCalls, id) +} + +// RequireUncertainModel checks Cancel projected the executing ModelStep. +func (f *Feature) RequireUncertainModel() { + f.t.Helper() + s := f.state() + if s.Result == nil || s.Result.UncertainModel == "" { + f.t.Fatalf("result = %+v, want UncertainModel", s.Result) + } +} + +// RequireAbsorbsCommands checks a further Cancel is rejected as terminal. +func (f *Feature) RequireAbsorbsCommands() { + f.t.Helper() + snap := f.load() + proto, err := snap.Protocol() + if err != nil { + f.t.Fatal(err) + } + env, err := proto.BuildEnvelope(defaultSession, f.runID, "after-terminal", run.CancelRun{}) + if err != nil { + f.t.Fatal(err) + } + _, err = f.rt.Commit(f.ctx, defaultSession, run.CommitRequest{Base: snap.Position, Command: env}) + if !errors.Is(err, run.ErrRunTerminal) { + f.t.Fatalf("err = %v, want ErrRunTerminal", err) + } +} diff --git a/agent/run/runtest/script.go b/agent/run/runtest/script.go new file mode 100644 index 0000000..665d060 --- /dev/null +++ b/agent/run/runtest/script.go @@ -0,0 +1,143 @@ +package runtest + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/sdk" +) + +// Text is a model result that completes without tool calls. +func Text(text string) sdk.ModelResult { + return sdk.ModelResult{Text: text, FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}} +} + +// Call is one scripted tool call with the default arguments. +func Call(name, id string) sdk.ToolCall { + return sdk.ToolCall{ToolCallID: id, ToolName: name, Input: `{"x":1}`} +} + +// Calls is a model result that opens the given tool calls. +func Calls(calls ...sdk.ToolCall) sdk.ModelResult { + return sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 2}, ToolCalls: calls} +} + +// ToolCalls is a model result that opens the named tool for each call ID. +func ToolCalls(name string, ids ...string) sdk.ModelResult { + calls := make([]sdk.ToolCall, len(ids)) + for i, id := range ids { + calls[i] = Call(name, id) + } + return Calls(calls...) +} + +type scriptInvoker struct { + results []sdk.ModelResult + calls atomic.Int32 + mu sync.Mutex + last *sdk.ModelResult // most recent result handed to the Loop +} + +func (s *scriptInvoker) Generate(ctx context.Context, _ sdk.Request) (sdk.ModelResult, error) { + if err := ctx.Err(); err != nil { + return sdk.ModelResult{}, err + } + n := int(s.calls.Add(1)) - 1 + if n >= len(s.results) { + return sdk.ModelResult{}, errors.New("runtest: no scripted model result") + } + res := s.results[n] + s.mu.Lock() + s.last = &res + s.mu.Unlock() + return res, nil +} + +func (s *scriptInvoker) lastResult() (sdk.ModelResult, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.last == nil { + return sdk.ModelResult{}, false + } + return *s.last, true +} + +type scriptCatalog struct { + invoker loop.ModelInvoker + err error +} + +func (c scriptCatalog) ResolveModel(run.ModelRef) (loop.ModelInvoker, error) { + if c.err != nil { + return nil, c.err + } + return c.invoker, nil +} + +type scriptTool struct { + ref run.ToolRef + def sdk.ToolDefinition + policy run.ResponsePolicy + unknown bool + fail string + ran atomic.Int32 +} + +func (s *scriptTool) Ref() run.ToolRef { return s.ref } +func (s *scriptTool) Definition() sdk.ToolDefinition { return s.def } +func (s *scriptTool) ResponsePolicy() run.ResponsePolicy { return s.policy } +func (s *scriptTool) ValidateArguments(run.CanonicalJSON) error { + return nil +} + +func (s *scriptTool) Execute(_ context.Context, req loop.ToolExecutionRequest) loop.ToolExecutionOutcome { + s.ran.Add(1) + if s.unknown { + return loop.ToolExecutionUnknown{Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: "lost"}} + } + if s.fail != "" { + return loop.ToolExecutionFailed{Failure: run.ToolFailure{Class: s.fail, Message: "boom"}} + } + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: req.Arguments}} +} + +type scriptToolCatalog struct { + tools map[run.ToolRef]loop.ExecutableTool +} + +func (c scriptToolCatalog) ResolveTool(ref run.ToolRef) (loop.ExecutableTool, error) { + tool, ok := c.tools[ref] + if !ok { + return nil, fmt.Errorf("unknown tool %q", ref) + } + return tool, nil +} + +type scriptPlanner struct { + model run.ModelRef + specs []run.ToolSpec + defs map[run.ToolRef]sdk.ToolDefinition + lastHint run.PlanningHint +} + +func (p *scriptPlanner) Plan(_ context.Context, hint run.PlanningHint) (loop.RequestPlan, error) { + p.lastHint = hint + model := p.model + if model == "" { + model = defaultModel + } + req := sdk.Request{Model: string(model), Messages: []sdk.Message{sdk.UserMessage("go")}} + for _, spec := range p.specs { + req.Tools = append(req.Tools, p.defs[spec.Ref]) + } + ids := make([]run.InputID, len(hint.Inputs)) + for i, in := range hint.Inputs { + ids[i] = in.ID + } + return loop.RequestPlan{Model: model, Request: req, InputIDs: ids, Tools: p.specs}, nil +} diff --git a/agent/run/runtest/tools_test.go b/agent/run/runtest/tools_test.go new file mode 100644 index 0000000..ab5a95e --- /dev/null +++ b/agent/run/runtest/tools_test.go @@ -0,0 +1,45 @@ +package runtest_test + +import ( + "testing" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/runtest" +) + +func TestModelCallCompletes(t *testing.T) { + f := runtest.New(t) + f.Model(runtest.Text("hello")) + f.Run() + f.RequireCompleted("hello") +} + +func TestToolRoundTripCompletes(t *testing.T) { + f := runtest.New(t) + f.Tool("echo", run.DirectExecution) + f.Model(runtest.ToolCalls("echo", "c1"), runtest.Text("done")) + f.Run() + f.RequireCompleted("done") + f.RequireRan("echo") + f.RequireModelCalls(2) + f.RequireUsage(3) + f.RequireFactOpened() + f.RequirePlannerSawTool("c1", `{"x":1}`) +} + +func TestKnownToolFailureContinues(t *testing.T) { + f := runtest.New(t) + f.KnownFailure("echo", run.FailureExecution) + f.Model(runtest.ToolCalls("echo", "c1"), runtest.Text("recovered")) + f.Run() + f.RequireCompleted("recovered") + f.RequireFailureClass(run.FailureExecution) +} + +func TestUnknownToolRefContinues(t *testing.T) { + f := runtest.New(t) + f.Model(runtest.ToolCalls("ghost", "c1"), runtest.Text("moved on")) + f.Run() + f.RequireCompleted("moved on") + f.RequireFailureClass(run.FailureToolLookup) +} diff --git a/agent/run/runtest/unknown_test.go b/agent/run/runtest/unknown_test.go new file mode 100644 index 0000000..22bfc20 --- /dev/null +++ b/agent/run/runtest/unknown_test.go @@ -0,0 +1,31 @@ +package runtest_test + +import ( + "testing" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/runtest" +) + +func TestUnknownToolOutcomeContinues(t *testing.T) { + f := runtest.New(t) + f.Unknown("echo") + f.Model(runtest.ToolCalls("echo", "c1"), runtest.Text("recovered")) + f.Run() + f.RequireCompleted("recovered") + f.RequireCallFailed("c1", run.ToolOutcomeUnknown) + f.RequireFailureClass(run.FailureEffectUnknown) +} + +func TestUnknownToolOutcomeLeavesSiblingRunning(t *testing.T) { + f := runtest.New(t) + f.Unknown("lost") + f.Tool("echo", run.DirectExecution) + f.Model(runtest.Calls(runtest.Call("lost", "c1"), runtest.Call("echo", "c2")), runtest.Text("done")) + f.Run() + f.RequireCompleted("done") + f.RequireRan("lost") + f.RequireRan("echo") + f.RequireCallFailed("c1", run.ToolOutcomeUnknown) + f.RequirePlannerSawTool("c2", `{"x":1}`) +} diff --git a/agent/run/runtime.go b/agent/run/runtime.go new file mode 100644 index 0000000..c5da667 --- /dev/null +++ b/agent/run/runtime.go @@ -0,0 +1,116 @@ +package run + +import ( + "context" + "errors" + + "github.com/felinics/twilight/agent/session" +) + +// RunPosition is the Seq of a Run's last twilight/run/ event. Only the Run's +// own events move it; other modules' rows in the same Session leave it +// untouched, which is what makes Prepare's hard CAS insensitive to concurrent +// chatlog or turn writes (RUN-CMT-4). +type RunPosition = session.Seq + +// ErrOwnershipLost reports that the Session Writer behind the Runtime was +// superseded (RUN-CMT-6). It is terminal for the caller: no further command of +// this process can reach the stream. +var ErrOwnershipLost = errors.New("agent: session ownership lost") + +// Runtime is the Run command entry (RUN-CMT-1): addressed by (SessionID, +// RunID), it evaluates commands inside the Session Writer and appends facts, +// companion content and attached events as one group. Runs are created by the +// Coordinator's Start group; there is no Create. +type Runtime interface { + Load(context.Context, session.SessionID, RunID) (RuntimeSnapshot, error) + Commit(context.Context, session.SessionID, CommitRequest) (CommitResult, error) + Record(context.Context, session.SessionID, RunID) (RunRecord, error) + // FrozenRequest returns the request body a Prepared or Executing ModelStep + // names by RequestDigest (RUN-WIR-4); a missing body is ErrFrozenValueMissing. + FrozenRequest(context.Context, Digest) (ModelRequest, error) + // RecoverInterrupted is the takeover disposition (RUN-CMT-7): one recovery + // command per Executing target of the Session. The host calls it once after + // opening the Writer and before driving any Run; it returns the number of + // accepted commands. + RecoverInterrupted(context.Context, session.SessionID) (int, error) +} + +type RuntimeSnapshot struct { + // State is a detached in-process view. + State MachineState + // Position is the Run's last event Seq at read time. + Position RunPosition + // Head is the Session head at read time. + Head session.Head + // SchemaVersion is created.SchemaVersion; Loop and Application select + // ProtocolFor(SchemaVersion) once. + SchemaVersion uint16 +} + +// Protocol returns the protocol frozen at the Run's creation. +func (s RuntimeSnapshot) Protocol() (Protocol, error) { + return ProtocolFor(s.SchemaVersion) +} + +// ModuleEvent is a typed event of another module (chatlog, turn) that the +// Runtime appends after the Run facts in the same group. The module +// implementation encodes it through the Registry. +type ModuleEvent struct { + Type session.EventType + Value any +} + +// CompanionRequest is what the Runtime hands the Companion after Evolve: +// the command (with its transient content), the facts and the new state. +type CompanionRequest struct { + Session session.SessionID + Owner OwnerID + RunID RunID + Command AgentCommand + Facts []Fact + State MachineState + RecordedAtUnixMilli int64 +} + +// Companion maps Run facts and the command's transient content to the +// conversation events that travel in the same group (TRN-CMP). Map must be a +// deterministic pure function. +type Companion interface { + Version() string + Map(CompanionRequest) ([]ModuleEvent, error) +} + +type CommitRequest struct { + // Base is the Position the caller loaded. PrepareModelRequest treats it + // as a hard CAS; other commands rebase call-locally and may pass zero + // (RUN-CMT-4). + Base RunPosition + Command CommandEnvelope + // Attach are caller events appended after the companion events; they must + // not be twilight/run/ events. + Attach []ModuleEvent +} + +type CommitStatus uint8 + +const ( + CommitAccepted CommitStatus = iota + CommitAlreadyApplied +) + +type CommitResult struct { + Status CommitStatus + Snapshot RuntimeSnapshot + // Events is the complete group: run facts, companion, attach. + Events []session.SessionEvent +} + +// RunRecord is one verified read of a Run: every twilight/run/ event of the +// RunID in Seq order, folded and compared with the projection. +type RunRecord struct { + Created session.Seq + Snapshot RuntimeSnapshot + Events []session.SessionEvent + Facts []Fact +} diff --git a/agent/run/snapshot.go b/agent/run/snapshot.go new file mode 100644 index 0000000..012a1f9 --- /dev/null +++ b/agent/run/snapshot.go @@ -0,0 +1,121 @@ +package run + +import ( + "errors" + "fmt" +) + +// machineStateWireV1 is the persisted snapshot shape of MachineState for +// SchemaVersion1. It flattens the interface-typed Current into a discriminator +// plus at most one step body so the snapshot round-trips through JSON. Its +// canonical bytes are the InitialStateDigest preimage (RUN-NEW-1), so field +// names and omission rules are frozen with the schema. +type machineStateWireV1 struct { + RunID RunID `json:"runId"` + Owner OwnerID `json:"owner,omitempty"` + Attempt uint32 `json:"attempt,omitempty"` + Status RunStatus `json:"status"` + ModelSteps int `json:"modelSteps"` + Usage Usage `json:"usage"` + PendingInputs []AgentInput `json:"pendingInputs"` + Result *RunResult `json:"result"` + LastToolStep *ToolStep `json:"lastToolStep,omitempty"` + // Current is "open", "model" or "tool" for an active Run and absent for a + // terminal one. + Current string `json:"current,omitempty"` + ModelStep *ModelStep `json:"modelStep,omitempty"` + ToolStep *ToolStep `json:"toolStep,omitempty"` +} + +const ( + currentWireOpen = "open" + currentWireModel = "model" + currentWireTool = "tool" +) + +func machineStateToWireV1(s *MachineState) (machineStateWireV1, error) { + w := machineStateWireV1{ + RunID: s.RunID, Owner: s.Owner, Attempt: s.Attempt, Status: s.Status, ModelSteps: s.ModelSteps, + Usage: s.Usage, PendingInputs: s.PendingInputs, + Result: s.Result, LastToolStep: s.LastToolStep, + } + switch cur := s.Current.(type) { + case nil: + case Open: + w.Current = currentWireOpen + case ModelStep: + w.Current = currentWireModel + w.ModelStep = &cur + case ToolStep: + w.Current = currentWireTool + w.ToolStep = &cur + default: + return machineStateWireV1{}, fmt.Errorf("agent: snapshot: unknown current variant %T", s.Current) + } + return w, nil +} + +func machineStateFromWireV1(w *machineStateWireV1) (MachineState, error) { + s := MachineState{ + RunID: w.RunID, Owner: w.Owner, Attempt: w.Attempt, Status: w.Status, ModelSteps: w.ModelSteps, + Usage: w.Usage, PendingInputs: w.PendingInputs, + Result: w.Result, LastToolStep: w.LastToolStep, + } + switch w.Current { + case "": + if w.ModelStep != nil || w.ToolStep != nil { + return MachineState{}, errors.New("agent: snapshot: step body without current discriminator") + } + case currentWireOpen: + if w.ModelStep != nil || w.ToolStep != nil { + return MachineState{}, errors.New("agent: snapshot: open state carries a step body") + } + s.Current = Open{} + case currentWireModel: + if w.ModelStep == nil || w.ToolStep != nil { + return MachineState{}, errors.New("agent: snapshot: model current requires exactly a modelStep body") + } + s.Current = *w.ModelStep + case currentWireTool: + if w.ToolStep == nil || w.ModelStep != nil { + return MachineState{}, errors.New("agent: snapshot: tool current requires exactly a toolStep body") + } + s.Current = *w.ToolStep + default: + return MachineState{}, fmt.Errorf("agent: snapshot: unknown current %q", w.Current) + } + return s, nil +} + +// encodeMachineStateV1 renders the canonical v1 snapshot bytes. +func encodeMachineStateV1(s *MachineState) ([]byte, error) { + if s == nil { + return nil, errors.New("agent: snapshot: nil state") + } + w, err := machineStateToWireV1(s) + if err != nil { + return nil, err + } + return marshalCanonical(w) +} + +// decodeMachineStateV1 parses v1 snapshot bytes, rejecting unknown fields, +// trailing data, and non-canonical-equivalent wire, then validates the +// structural invariants of the restored state. +func decodeMachineStateV1(raw []byte) (MachineState, error) { + var w machineStateWireV1 + if err := decodeStrictJSON(raw, &w); err != nil { + return MachineState{}, fmt.Errorf("agent: snapshot: %w", err) + } + s, err := machineStateFromWireV1(&w) + if err != nil { + return MachineState{}, err + } + if err := requireCanonicalEquivalent(raw, w); err != nil { + return MachineState{}, fmt.Errorf("agent: snapshot: %w", err) + } + if err := ValidateMachineState(&s); err != nil { + return MachineState{}, err + } + return s, nil +} diff --git a/agent/run/snapshot_test.go b/agent/run/snapshot_test.go new file mode 100644 index 0000000..6c11a42 --- /dev/null +++ b/agent/run/snapshot_test.go @@ -0,0 +1,71 @@ +package run + +import ( + "strings" + "testing" +) + +// The snapshot codec round-trips every Current variant and the terminal +// shape, and its bytes equal statesEquivalent's identity. +func TestSnapshotCodecRoundTrip(t *testing.T) { + def := testToolDef("t") + spec := makeSpec(t, def, DirectExecution) + check := func(name string, s MachineState) { + t.Helper() + raw, err := ProtocolV1().EncodeMachineState(&s) + if err != nil { + t.Fatalf("%s: encode: %v", name, err) + } + decoded, err := ProtocolV1().DecodeMachineState(raw) + if err != nil { + t.Fatalf("%s: decode: %v\n%s", name, err, raw) + } + if !statesEquivalent(&s, &decoded) { + t.Fatalf("%s: round trip changed state\n%s", name, raw) + } + if (s.Current == nil) != (decoded.Current == nil) { + t.Fatalf("%s: Current presence changed: %T -> %T", name, s.Current, decoded.Current) + } + } + + s := newRun(t) + check("open", s) + s, stepID := advanceToExecuting(t, s, testRequest(def), []ToolSpec{spec}) + check("model executing", s) + b := makeBinding(t, stepID, 0, "c1", spec, `{}`) + facts := mustDecide(t, s, SubmitModelResult{StepID: stepID, Result: modelResultWithCalls("c1"), Calls: []ToolCallBinding{b}}) + s = fold(t, s, facts) + check("tool step pending", s) + toolStep := facts[1].(ToolStepOpened).StepID + s = fold(t, s, mustDecide(t, s, StartToolCall{StepID: toolStep, CallID: cid(stepID, 0), Claim: "claim"})) + check("tool step executing", s) + s = fold(t, s, mustDecide(t, s, SubmitToolResult{StepID: toolStep, CallID: cid(stepID, 0), Result: ToolExecutionResult{Output: cj(`"ok"`)}})) + check("open with last tool step", s) + s = fold(t, s, mustDecide(t, s, CancelRun{})) + check("terminal", s) +} + +func TestSnapshotCodecRejectsMalformedWire(t *testing.T) { + initial, err := InitializeRun("run-1", "", 0) + if err != nil { + t.Fatal(err) + } + good, err := ProtocolV1().EncodeMachineState(&initial) + if err != nil { + t.Fatal(err) + } + for name, raw := range map[string]string{ + "unknown field": strings.Replace(string(good), `"runId"`, `"extra":1,"runId"`, 1), + "unknown current": strings.Replace(string(good), `"current":"open"`, `"current":"weird"`, 1), + "open with step body": strings.Replace(string(good), `"current":"open"`, `"current":"open","modelStep":{}`, 1), + "active without current": strings.Replace(string(good), `"current":"open",`, ``, 1), + "trailing data": string(good) + `{}`, + } { + if _, err := ProtocolV1().DecodeMachineState([]byte(raw)); err == nil { + t.Fatalf("%s: accepted\n%s", name, raw) + } + } + if _, err := ProtocolV1().DecodeMachineState(good); err != nil { + t.Fatalf("canonical wire rejected: %v", err) + } +} diff --git a/agent/run/state.go b/agent/run/state.go new file mode 100644 index 0000000..4f00d41 --- /dev/null +++ b/agent/run/state.go @@ -0,0 +1,520 @@ +package run + +import ( + "errors" + "fmt" +) + +type RunStatus uint8 + +const ( + RunActive RunStatus = iota + RunCompleted + RunStopped + RunFailed +) + +func (s RunStatus) Terminal() bool { return s != RunActive } + +type RunReason string + +const ( + ReasonCancelled RunReason = "cancelled" + ReasonProviderFailure RunReason = "provider_failure" + ReasonMalformedModel RunReason = "malformed_model_result" + // ReasonEffectUnknown is unused as a RunEnded reason. Unknown tool + // outcomes use FailureEffectUnknown on ToolCallFailed and leave the Run + // active. + ReasonEffectUnknown RunReason = "effect_unknown" +) + +type RunFailure struct { + Class string `json:"class"` + Message string `json:"message,omitempty"` + CallID CallID `json:"callId,omitempty"` +} + +type RunResult struct { + Status RunStatus `json:"status"` + Reason RunReason `json:"reason,omitempty"` + Failure *RunFailure `json:"failure,omitempty"` + // UncertainCalls are tool calls left Executing when the Run stopped. + UncertainCalls []CallID `json:"uncertainCalls,omitempty"` + // UncertainModel is the ModelStep left Executing when the Run stopped. + UncertainModel StepID `json:"uncertainModel,omitempty"` + Usage Usage `json:"usage"` +} + +type StepFailure struct { + Class string `json:"class"` + Message string `json:"message,omitempty"` +} + +const ( + FailurePermissionDenied = "permission_denied" + FailureResponseRejected = "response_rejected" + FailureToolLookup = "tool_lookup_failed" + FailureInvalidArguments = "invalid_arguments" + FailureMalformedModel = "malformed_model_result" + FailureDefinitionMismatch = "tool_definition_mismatch" + FailureExecution = "execution_failed" + FailureEffectUnknown = "effect_unknown" + FailureProvider = "provider_failure" +) + +type ResponsePolicy uint8 + +const ( + DirectExecution ResponsePolicy = iota + ApprovalRequired + ExternalResponse +) + +type ResponseKind string + +const ( + ResponseApproval ResponseKind = "approval" + ResponseExternal ResponseKind = "external_response" +) + +type ResponseDecision string + +const ( + ResponseDecisionApproved ResponseDecision = "approved" + ResponseDecisionRejected ResponseDecision = "rejected" +) + +// ResponseRequest is the stable, routable identity of one waiting call. +type ResponseRequest struct { + RunID RunID `json:"runId"` + StepID StepID `json:"stepId"` + CallID CallID `json:"callId"` + ID ResponseID `json:"id"` + Kind ResponseKind `json:"kind"` + Payload CanonicalJSON `json:"payload,omitzero"` + RequestDigest Digest `json:"requestDigest"` // digest of the request payload +} + +// ToolSpec is the agent-side sidecar for a provider-neutral ToolDefinition. +// The definition body lives inside the frozen request (RUN-WIR-4); the spec +// keeps its model-facing Name for binding tool calls, the digest for +// execution-time verification, and the ResponsePolicy, which is intentionally +// kept out of sdk to preserve package layering. +type ToolSpec struct { + Ref ToolRef `json:"ref"` + Name string `json:"name"` + DefinitionDigest Digest `json:"definitionDigest"` + Policy ResponsePolicy `json:"policy"` +} + +// ToolCallBinding is one frozen call inside ToolStepOpened. +type ToolCallBinding struct { + CallID CallID `json:"callId"` + // ProviderCallID is the tool_call_id the model emitted. Planners echo it + // back when they replay the call and its result; the Run never keys on it. + ProviderCallID string `json:"providerCallId,omitempty"` + ToolRef ToolRef `json:"toolRef"` + DefinitionDigest Digest `json:"definitionDigest"` + BindingDigest Digest `json:"bindingDigest"` // definition, policy and canonical arguments + Arguments CanonicalJSON `json:"arguments"` + Policy ResponsePolicy `json:"policy"` // unresolved ToolRef uses DirectExecution + // Response is derived and filled by Decide inside ToolStepOpened; callers + // leave it empty when submitting. + Response *ResponseRequest `json:"response,omitempty"` +} + +type StepRef struct { + RunID RunID `json:"runId"` + ID StepID `json:"id"` + Digest Digest `json:"digest"` // immutable step binding digest; progress is not included +} + +// Step is sealed by the agent package: only ModelStep and ToolStep exist. +type Step interface { + step() + Ref() StepRef +} + +// Current is the contents of an Active run. Open is the planning interval: +// Prepare is legal and Next returns NeedModelRequest. AcceptInput is legal in +// every non-terminal state; PendingInputs is the durable queue it feeds. +type Current interface{ current() } + +// Open is Active with no ModelStep or ToolStep. +type Open struct{} + +func (Open) current() {} + +func atOpen(c Current) bool { + _, ok := c.(Open) + return ok +} + +type ModelStepStatus uint8 + +const ( + ModelPrepared ModelStepStatus = iota + ModelExecuting +) + +func (s ModelStepStatus) String() string { + switch s { + case ModelPrepared: + return "Prepared" + case ModelExecuting: + return "Executing" + default: + return fmt.Sprintf("ModelStepStatus(%d)", uint8(s)) + } +} + +type ModelStep struct { + RefValue StepRef `json:"ref"` + // RequestDigest identifies the frozen request; its body is kept in the + // FrozenValueStore for the life of the step (RUN-WIR-4). + RequestDigest Digest `json:"requestDigest"` + Model ModelRef `json:"model"` + Tools []ToolSpec `json:"tools,omitempty"` + ToolsDigest Digest `json:"toolsDigest"` + Status ModelStepStatus `json:"status"` + // Rejects counts accepted ModelStepRejected facts; progress, not part of + // RefValue.Digest. + Rejects int `json:"rejects,omitempty"` +} + +func (ModelStep) step() {} +func (ModelStep) current() {} + +//nolint:gocritic // hugeParam: value receiver keeps ModelStep satisfying sealed Step as a value. +func (s ModelStep) Ref() StepRef { return s.RefValue } + +type ToolCallStatus uint8 + +const ( + ToolPending ToolCallStatus = iota + ToolExecuting + ToolWaiting + ToolCompleted + ToolFailed +) + +func (s ToolCallStatus) String() string { + switch s { + case ToolPending: + return "Pending" + case ToolExecuting: + return "Executing" + case ToolWaiting: + return "Waiting" + case ToolCompleted: + return "Completed" + case ToolFailed: + return "Failed" + default: + return fmt.Sprintf("ToolCallStatus(%d)", uint8(s)) + } +} + +// Terminal reports Completed or Failed. +func (s ToolCallStatus) Terminal() bool { return s == ToolCompleted || s == ToolFailed } + +// ToolExecutionResult is the transient output a tool worker submits. The +// state and the fact keep only its digest; the content is carried to the +// conversation by the companion (RUN-WIR-4). +type ToolExecutionResult struct { + Output CanonicalJSON `json:"output"` +} + +// ToolCallResult is the persisted record of a completed call. +type ToolCallResult struct { + OutputDigest Digest `json:"outputDigest"` +} + +type ToolFailure struct { + Class string `json:"class"` + Message string `json:"message,omitempty"` +} + +type ToolFailureOutcome uint8 + +const ( + ToolOutcomeKnown ToolFailureOutcome = iota + ToolOutcomeUnknown +) + +type ToolCallFailure struct { + Failure ToolFailure `json:"failure"` + Outcome ToolFailureOutcome `json:"outcome"` +} + +type ToolCallState struct { + CallID CallID `json:"callId"` + ProviderCallID string `json:"providerCallId,omitempty"` + ToolRef ToolRef `json:"toolRef"` + DefinitionDigest Digest `json:"definitionDigest"` + BindingDigest Digest `json:"bindingDigest"` + Arguments CanonicalJSON `json:"arguments"` + Policy ResponsePolicy `json:"policy"` + Status ToolCallStatus `json:"status"` + Result *ToolCallResult `json:"result,omitempty"` + Failure *ToolCallFailure `json:"failure,omitempty"` + Waiting *ResponseRequest `json:"waiting,omitempty"` +} + +// ValidateToolCallState rejects illegal field combinations (RUN-MCH-2). +// +//nolint:gocritic // hugeParam: public validator accepts the value stored in facts/state without mutating it. +func ValidateToolCallState(c ToolCallState) error { + switch c.Status { + case ToolPending, ToolExecuting: + if c.Result != nil || c.Failure != nil || c.Waiting != nil { + return fmt.Errorf("agent: call %s: pending/executing must have no result/failure/waiting", c.CallID) + } + case ToolWaiting: + if c.Waiting == nil { + return fmt.Errorf("agent: call %s: waiting requires a ResponseRequest", c.CallID) + } + if c.Policy != ApprovalRequired && c.Policy != ExternalResponse { + return fmt.Errorf("agent: call %s: waiting requires approval or external-response policy", c.CallID) + } + if c.Result != nil || c.Failure != nil { + return fmt.Errorf("agent: call %s: waiting must have no result/failure", c.CallID) + } + case ToolCompleted: + if c.Result == nil || c.Result.OutputDigest == "" { + return fmt.Errorf("agent: call %s: completed requires a result digest", c.CallID) + } + if c.Failure != nil || c.Waiting != nil { + return fmt.Errorf("agent: call %s: completed must have no failure/waiting", c.CallID) + } + case ToolFailed: + if c.Failure == nil { + return fmt.Errorf("agent: call %s: failed requires a failure", c.CallID) + } + if c.Result != nil || c.Waiting != nil { + return fmt.Errorf("agent: call %s: failed must have no result/waiting", c.CallID) + } + if c.Failure.Failure.Class == "" { + return fmt.Errorf("agent: call %s: failed requires a failure class", c.CallID) + } + switch c.Failure.Outcome { + case ToolOutcomeKnown: + if c.Failure.Failure.Class == FailureEffectUnknown { + return fmt.Errorf("agent: call %s: known outcome cannot use %s", c.CallID, FailureEffectUnknown) + } + case ToolOutcomeUnknown: + if c.Failure.Failure.Class != FailureEffectUnknown { + return fmt.Errorf("agent: call %s: unknown outcome must use %s", c.CallID, FailureEffectUnknown) + } + default: + return fmt.Errorf("agent: call %s: unknown failure outcome %d", c.CallID, c.Failure.Outcome) + } + default: + return fmt.Errorf("agent: call %s: unknown status %d", c.CallID, c.Status) + } + return nil +} + +// ToolScheduleMode is frozen onto a ToolStep at open. Resume must honor it. +type ToolScheduleMode string + +const ( + ToolScheduleParallel ToolScheduleMode = "parallel" + ToolScheduleSequential ToolScheduleMode = "sequential" +) + +// ToolScheduling is the durable dispatch constraint for one ToolStep. +// Empty Mode means parallel. MaxParallel 0 means every Pending call in the +// current Start batch may run; a positive value caps that batch. +type ToolScheduling struct { + Mode ToolScheduleMode `json:"mode,omitempty"` + MaxParallel int `json:"maxParallel,omitempty"` +} + +func normalizeToolScheduling(s ToolScheduling) (ToolScheduling, error) { + if s.Mode != "" && s.Mode != ToolScheduleParallel && s.Mode != ToolScheduleSequential { + return ToolScheduling{}, fmt.Errorf("unknown mode %q", s.Mode) + } + if s.MaxParallel < 0 { + return ToolScheduling{}, errors.New("negative MaxParallel") + } + return s, nil +} + +type ToolStep struct { + RefValue StepRef `json:"ref"` + Source StepID `json:"source"` + Calls []ToolCallState `json:"calls"` + Scheduling ToolScheduling `json:"scheduling,omitzero"` +} + +func (ToolStep) step() {} +func (ToolStep) current() {} + +//nolint:gocritic // hugeParam: value receiver keeps ToolStep satisfying sealed Step as a value. +func (s ToolStep) Ref() StepRef { return s.RefValue } + +func (s *ToolStep) callIndex(id CallID) int { + for i := range s.Calls { + if s.Calls[i].CallID == id { + return i + } + } + return -1 +} + +// MachineState is the complete semantic state of one Run (RUN-MCH-1). +// Control metadata (owner, fence, lease, attempts, queue claims) never +// appears here. Content bodies (model output, tool output) never appear +// either: facts record digests and the companion carries the content. +type MachineState struct { + RunID RunID `json:"runId"` + // Owner is the opaque upper-level identity this Run serves; Attempt is its + // ordinal under that owner. Both are fixed by RunCreated. + Owner OwnerID `json:"owner,omitempty"` + Attempt uint32 `json:"attempt,omitempty"` + Status RunStatus `json:"status"` + Current Current `json:"-"` + PendingInputs []AgentInput `json:"pendingInputs,omitempty"` + ModelSteps int `json:"modelSteps"` + // LastToolStep retains the most recently closed ToolStep so the planner can + // locate the step boundary it continues from. Its RefValue.ID is the + // SourceStep of the next PlanningHint. + LastToolStep *ToolStep `json:"lastToolStep,omitempty"` + Usage Usage `json:"usage"` + Result *RunResult `json:"result,omitempty"` +} + +// ValidateMachineState checks the structural invariants required by Runtime +// snapshots. It does not inspect transition history; Record and Rebuild use +// FoldRun for that stronger verification. +func ValidateMachineState(s *MachineState) error { + if s == nil { + return errors.New("agent: state: nil state") + } + if s.RunID == "" { + return errors.New("agent: state: empty RunID") + } + switch s.Status { + case RunActive, RunCompleted, RunStopped, RunFailed: + default: + return fmt.Errorf("agent: state: unknown RunStatus %d", s.Status) + } + if s.ModelSteps < 0 { + return errors.New("agent: state: negative model step count") + } + if err := validatePendingInputs(s.PendingInputs); err != nil { + return err + } + if err := validateLastToolStep(s); err != nil { + return err + } + if s.Status.Terminal() { + if s.Current != nil { + return errors.New("agent: state: terminal state has a current step") + } + if s.Result == nil || s.Result.Status != s.Status { + return errors.New("agent: state: terminal state has no matching result") + } + return nil + } + if s.Result != nil { + return errors.New("agent: state: active state has a result") + } + return validateCurrent(s) +} + +func validatePendingInputs(inputs []AgentInput) error { + seen := make(map[InputID]struct{}, len(inputs)) + for _, input := range inputs { + if input.ID == "" { + return errors.New("agent: state: pending input has empty InputID") + } + if _, dup := seen[input.ID]; dup { + return fmt.Errorf("agent: state: duplicate pending InputID %q", input.ID) + } + seen[input.ID] = struct{}{} + } + return nil +} + +func validateLastToolStep(s *MachineState) error { + last := s.LastToolStep + if last == nil { + return nil + } + if last.RefValue.RunID != s.RunID || last.RefValue.ID == "" || last.RefValue.Digest == "" || last.Source == "" { + return errors.New("agent: state: invalid LastToolStep projection") + } + if len(last.Calls) == 0 { + return errors.New("agent: state: LastToolStep has no calls") + } + for i := range last.Calls { + if err := ValidateToolCallState(last.Calls[i]); err != nil { + return err + } + if !last.Calls[i].Status.Terminal() { + return errors.New("agent: state: LastToolStep contains a live call") + } + } + return nil +} + +func validateCurrent(s *MachineState) error { + switch current := s.Current.(type) { + case Open: + return nil + case nil: + return errors.New("agent: state: active state has no current") + case ModelStep: + if current.RefValue.RunID != s.RunID || current.RefValue.ID == "" || current.RefValue.Digest == "" || current.Model == "" { + return errors.New("agent: state: invalid current ModelStep identity") + } + if current.Status != ModelPrepared && current.Status != ModelExecuting { + return fmt.Errorf("agent: state: unknown ModelStep status %d", current.Status) + } + return nil + case ToolStep: + return validateCurrentToolStep(s.RunID, ¤t) + default: + return fmt.Errorf("agent: state: unknown current step %T", s.Current) + } +} + +func validateCurrentToolStep(runID RunID, ts *ToolStep) error { + if ts.RefValue.RunID != runID || ts.RefValue.ID == "" || ts.RefValue.Digest == "" || ts.Source == "" || len(ts.Calls) == 0 { + return errors.New("agent: state: invalid current ToolStep identity") + } + seen := make(map[CallID]struct{}, len(ts.Calls)) + live := false + for i := range ts.Calls { + call := &ts.Calls[i] + if call.CallID == "" { + return errors.New("agent: state: current ToolStep has empty CallID") + } + if _, dup := seen[call.CallID]; dup { + return fmt.Errorf("agent: state: duplicate CallID %q", call.CallID) + } + seen[call.CallID] = struct{}{} + if err := ValidateToolCallState(*call); err != nil { + return err + } + if !call.Status.Terminal() { + live = true + } + } + if !live { + return errors.New("agent: state: current ToolStep has no live calls") + } + return nil +} + +// InitializeRun builds the minimal initial MachineState (Revision 0) for a +// new Run. It does not encode fixed-model policy, limits, or seed input; those +// belong to host policy and accepted transitions. +func InitializeRun(run RunID, owner OwnerID, attempt uint32) (MachineState, error) { + if run == "" { + return MachineState{}, errors.New("agent: initialize: empty RunID") + } + return MachineState{RunID: run, Owner: owner, Attempt: attempt, Status: RunActive, Current: Open{}}, nil +} diff --git a/agent/session/chatlog/chatlog.go b/agent/session/chatlog/chatlog.go new file mode 100644 index 0000000..1404d58 --- /dev/null +++ b/agent/session/chatlog/chatlog.go @@ -0,0 +1,500 @@ +// Package chatlog is the first-party conversation-content module +// (docs/design/agent-session-chatlog.md): Input, assistant, tool_result and +// summary entries, their canonical codec, and the Surface and Context +// projections. Turn lifecycle belongs to agent/turn; execution facts to +// agent/run. +package chatlog + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/felinics/twilight/agent/artifact" + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/extension" +) + +const ModuleID extension.ModuleID = "chatlog" + +type ( + TurnID string + InputID string + AssistantID string + ToolResultID string + SummaryID string + CallID string + CheckpointID string +) + +// EventTypes (CHT-EVT-1). v1 companion and coordinator write the first six; +// checkpoints are written by the host's compaction (CHT-EVT-3). +const ( + TypeInputSubmitted session.EventType = "twilight/chatlog/input_submitted" + TypeInputDelivered session.EventType = "twilight/chatlog/input_delivered" + TypeInputWithdrawn session.EventType = "twilight/chatlog/input_withdrawn" + TypeInputRejected session.EventType = "twilight/chatlog/input_rejected" + TypeAssistant session.EventType = "twilight/chatlog/assistant" + TypeToolResult session.EventType = "twilight/chatlog/tool_result" + TypeToolResultSuperseded session.EventType = "twilight/chatlog/tool_result_superseded" + TypeSummary session.EventType = "twilight/chatlog/summary" + TypeCheckpointCreated session.EventType = "twilight/chatlog/checkpoint_created" + TypeCheckpointInvalidated session.EventType = "twilight/chatlog/checkpoint_invalidated" +) + +// --- parts -------------------------------------------------------------------- + +type PartKind string + +const ( + PartText PartKind = "twilight/chatlog/text" + PartReasoning PartKind = "twilight/chatlog/reasoning" + PartToolCall PartKind = "twilight/chatlog/tool_call" + PartReference PartKind = "twilight/chatlog/reference" +) + +type Part interface{ PartKind() PartKind } + +type TextPart struct{ Text string } +type ReasoningPart struct{ Text string } +type ToolCallPart struct { + CallID CallID + ProviderCallID string + Name string + Input jsonstable.Value +} +type ReferencePart struct { + BindingID artifact.BindingID + Name string +} + +func (TextPart) PartKind() PartKind { return PartText } +func (ReasoningPart) PartKind() PartKind { return PartReasoning } +func (ToolCallPart) PartKind() PartKind { return PartToolCall } +func (ReferencePart) PartKind() PartKind { return PartReference } + +// Parts is the ordered part list with its discriminated-union wire. +type Parts []Part + +type partWire struct { + Kind PartKind `json:"kind"` + Text string `json:"text,omitempty"` + CallID CallID `json:"callId,omitempty"` + ProviderCallID string `json:"providerCallId,omitempty"` + Name string `json:"name,omitempty"` + Input *jsonstable.Value `json:"input,omitempty"` + BindingID string `json:"bindingId,omitempty"` +} + +func (ps Parts) MarshalJSON() ([]byte, error) { + wires := make([]partWire, 0, len(ps)) + for i, p := range ps { + w, err := encodePart(p) + if err != nil { + return nil, fmt.Errorf("part %d: %w", i, err) + } + wires = append(wires, w) + } + return json.Marshal(wires) +} + +func (ps *Parts) UnmarshalJSON(raw []byte) error { + value, err := jsonstable.Parse(raw) + if err != nil { + return err + } + var wires []partWire + if err := extension.StrictDecode(value, &wires); err != nil { + return err + } + out := make(Parts, 0, len(wires)) + for i := range wires { + p, err := decodePart(&wires[i]) + if err != nil { + return fmt.Errorf("part %d: %w", i, err) + } + out = append(out, p) + } + *ps = out + return nil +} + +func encodePart(p Part) (partWire, error) { + switch v := p.(type) { + case TextPart: + return partWire{Kind: PartText, Text: v.Text}, nil + case ReasoningPart: + return partWire{Kind: PartReasoning, Text: v.Text}, nil + case ToolCallPart: + if v.CallID == "" || v.Name == "" || v.Input.IsZero() { + return partWire{}, errors.New("tool_call part requires callId, name and input") + } + input := v.Input + return partWire{Kind: PartToolCall, CallID: v.CallID, ProviderCallID: v.ProviderCallID, Name: v.Name, Input: &input}, nil + case ReferencePart: + if v.BindingID == "" { + return partWire{}, errors.New("reference part requires bindingId") + } + return partWire{Kind: PartReference, BindingID: string(v.BindingID), Name: v.Name}, nil + default: + return partWire{}, fmt.Errorf("unknown part %T", p) + } +} + +func decodePart(w *partWire) (Part, error) { + switch w.Kind { + case PartText: + if w.CallID != "" || w.BindingID != "" || w.Input != nil { + return nil, errors.New("text part carries foreign fields") + } + return TextPart{Text: w.Text}, nil + case PartReasoning: + if w.CallID != "" || w.BindingID != "" || w.Input != nil { + return nil, errors.New("reasoning part carries foreign fields") + } + return ReasoningPart{Text: w.Text}, nil + case PartToolCall: + if w.CallID == "" || w.Name == "" || w.Input == nil || w.Input.IsZero() || w.Text != "" || w.BindingID != "" { + return nil, errors.New("malformed tool_call part") + } + return ToolCallPart{CallID: w.CallID, ProviderCallID: w.ProviderCallID, Name: w.Name, Input: *w.Input}, nil + case PartReference: + if w.BindingID == "" || w.Text != "" || w.CallID != "" { + return nil, errors.New("malformed reference part") + } + return ReferencePart{BindingID: artifact.BindingID(w.BindingID), Name: w.Name}, nil + default: + return nil, fmt.Errorf("unknown part kind %q", w.Kind) + } +} + +// --- entries ------------------------------------------------------------------ + +type ToolResultStatus string + +const ( + ToolSuccess ToolResultStatus = "success" + ToolError ToolResultStatus = "error" + ToolUnknown ToolResultStatus = "unknown" +) + +type Input struct { + ID InputID `json:"id"` + TurnID TurnID `json:"turnId,omitempty"` + Content jsonstable.Value `json:"content"` + Digest es.Digest `json:"digest"` +} + +type Assistant struct { + ID AssistantID `json:"id"` + TurnID TurnID `json:"turnId"` + Parts Parts `json:"parts"` + SourceDigest es.Digest `json:"sourceDigest,omitempty"` + Digest es.Digest `json:"digest"` +} + +type ToolResult struct { + ID ToolResultID `json:"id"` + TurnID TurnID `json:"turnId"` + CallID CallID `json:"callId"` + Status ToolResultStatus `json:"status"` + Parts Parts `json:"parts"` + SourceDigest es.Digest `json:"sourceDigest,omitempty"` + Digest es.Digest `json:"digest"` +} + +type Summary struct { + ID SummaryID `json:"id"` + Parts Parts `json:"parts"` + Digest es.Digest `json:"digest"` +} + +// Digests (CHT-COD-3): domain equals the EventType; `v` is not covered. + +func DigestInput(id InputID, content jsonstable.Value) (es.Digest, error) { + return digestDomain(TypeInputSubmitted, struct { + ID InputID `json:"id"` + Content jsonstable.Value `json:"content"` + }{id, content}) +} + +func DigestAssistant(a *Assistant) (es.Digest, error) { + return digestDomain(TypeAssistant, struct { + ID AssistantID `json:"id"` + TurnID TurnID `json:"turnId"` + Parts Parts `json:"parts"` + SourceDigest es.Digest `json:"sourceDigest,omitempty"` + }{a.ID, a.TurnID, a.Parts, a.SourceDigest}) +} + +func DigestToolResult(r *ToolResult) (es.Digest, error) { + return digestDomain(TypeToolResult, struct { + ID ToolResultID `json:"id"` + TurnID TurnID `json:"turnId"` + CallID CallID `json:"callId"` + Status ToolResultStatus `json:"status"` + Parts Parts `json:"parts"` + SourceDigest es.Digest `json:"sourceDigest,omitempty"` + }{r.ID, r.TurnID, r.CallID, r.Status, r.Parts, r.SourceDigest}) +} + +func DigestSummary(s *Summary) (es.Digest, error) { + return digestDomain(TypeSummary, struct { + ID SummaryID `json:"id"` + Parts Parts `json:"parts"` + }{s.ID, s.Parts}) +} + +// EntryDigestPair names one active Context entry (CHT-EVT-3). +type EntryDigestPair struct { + Kind EntryKind `json:"kind"` + ID string `json:"id"` + Digest es.Digest `json:"digest"` +} + +// DigestBaseContext covers the ordered active Context sequence a checkpoint +// replaces. An empty base digests as nil (empty and nil are one wire value). +func DigestBaseContext(pairs []EntryDigestPair) (es.Digest, error) { + if len(pairs) == 0 { + pairs = nil + } + return digestDomain(TypeCheckpointCreated, struct { + Base []EntryDigestPair `json:"base"` + }{pairs}) +} + +// DigestCheckpoint covers every checkpoint field except Digest itself. +func DigestCheckpoint(p *CheckpointCreatedPayload) (es.Digest, error) { + retained := p.Retained + if len(retained) == 0 { + retained = nil + } + return digestDomain(TypeCheckpointCreated, struct { + CheckpointID CheckpointID `json:"checkpointId"` + CoveredThrough session.Seq `json:"coveredThrough"` + BaseContextDigest es.Digest `json:"baseContextDigest"` + SummaryID SummaryID `json:"summaryId"` + SummaryDigest es.Digest `json:"summaryDigest"` + Retained []EntryDigestPair `json:"retained,omitempty"` + }{p.CheckpointID, p.CoveredThrough, p.BaseContextDigest, p.SummaryID, p.SummaryDigest, retained}) +} + +func digestDomain(typ session.EventType, body any) (es.Digest, error) { + raw, err := es.EncodeTypedPayload(1, string(typ), body) + if err != nil { + return "", err + } + return es.DigestBytes(raw), nil +} + +// --- payloads (CHT 5) ----------------------------------------------------------- + +type InputSubmittedPayload struct { + InputID InputID `json:"inputId"` + Content jsonstable.Value `json:"content"` + SubmittedAtUnixMilli int64 `json:"submittedAtUnixMilli"` +} +type InputDeliveredPayload struct { + InputID InputID `json:"inputId"` + TurnID TurnID `json:"turnId"` +} +type InputWithdrawnPayload struct { + InputID InputID `json:"inputId"` + Reason string `json:"reason,omitempty"` +} +type InputRejectedPayload struct { + InputID InputID `json:"inputId"` + Reason string `json:"reason,omitempty"` +} +type AssistantPayload struct { + Assistant Assistant `json:"assistant"` +} + +// SourceDigest lets the run Runtime verify the assistant names a digest that a +// fact of the same commit recorded (TRN-MAP-3). +func (p AssistantPayload) SourceDigest() es.Digest { return p.Assistant.SourceDigest } + +type ToolResultPayload struct { + ToolResult ToolResult `json:"toolResult"` +} + +// SourceDigest is the fact-recorded digest of the tool output (TRN-MAP-3). +func (p ToolResultPayload) SourceDigest() es.Digest { return p.ToolResult.SourceDigest } + +type ToolResultSupersededPayload struct { + ToolResultID ToolResultID `json:"toolResultId"` + ReplacementToolResultID ToolResultID `json:"replacementToolResultId"` +} +type SummaryPayload struct { + Summary Summary `json:"summary"` +} + +// CheckpointCreatedPayload compacts the context (CHT-EVT-3): entries up to +// CoveredThrough are replaced by the summary plus the Retained subset. +type CheckpointCreatedPayload struct { + CheckpointID CheckpointID `json:"checkpointId"` + CoveredThrough session.Seq `json:"coveredThrough"` + BaseContextDigest es.Digest `json:"baseContextDigest"` + SummaryID SummaryID `json:"summaryId"` + SummaryDigest es.Digest `json:"summaryDigest"` + Retained []EntryDigestPair `json:"retained,omitempty"` + Digest es.Digest `json:"digest"` +} + +type CheckpointInvalidatedPayload struct { + CheckpointID CheckpointID `json:"checkpointId"` + Reason string `json:"reason,omitempty"` +} + +func checkAssistant(p *AssistantPayload) error { + a := &p.Assistant + if a.ID == "" || a.TurnID == "" { + return errors.New("assistant requires id and turnId") + } + want, err := DigestAssistant(a) + if err != nil { + return err + } + if a.Digest != want { + return errors.New("assistant digest mismatch") + } + return nil +} + +func checkToolResult(p *ToolResultPayload) error { + r := &p.ToolResult + if r.ID == "" || r.TurnID == "" || r.CallID == "" { + return errors.New("tool_result requires id, turnId and callId") + } + switch r.Status { + case ToolSuccess, ToolError, ToolUnknown: + default: + return fmt.Errorf("unknown tool_result status %q", r.Status) + } + for _, part := range r.Parts { + switch part.(type) { + case TextPart, ReferencePart: + default: + return errors.New("tool_result parts must be text or reference") + } + } + want, err := DigestToolResult(r) + if err != nil { + return err + } + if r.Digest != want { + return errors.New("tool_result digest mismatch") + } + return nil +} + +func checkCheckpointCreated(p *CheckpointCreatedPayload) error { + if p.CheckpointID == "" || p.SummaryID == "" || p.BaseContextDigest == "" || p.SummaryDigest == "" { + return errors.New("checkpoint requires checkpointId, summaryId and both digests") + } + for _, pair := range p.Retained { + switch pair.Kind { + case EntryInput, EntryAssistant, EntryToolResult, EntrySummary: + default: + return fmt.Errorf("retained entry has unknown kind %q", pair.Kind) + } + if pair.ID == "" || pair.Digest == "" { + return errors.New("retained entry requires id and digest") + } + } + want, err := DigestCheckpoint(p) + if err != nil { + return err + } + if p.Digest != want { + return errors.New("checkpoint digest mismatch") + } + return nil +} + +func checkCheckpointInvalidated(p *CheckpointInvalidatedPayload) error { + if p.CheckpointID == "" { + return errors.New("checkpoint_invalidated requires checkpointId") + } + return nil +} + +func checkSummary(p *SummaryPayload) error { + if p.Summary.ID == "" { + return errors.New("summary requires id") + } + want, err := DigestSummary(&p.Summary) + if err != nil { + return err + } + if p.Summary.Digest != want { + return errors.New("summary digest mismatch") + } + return nil +} + +// PartsExtractor returns the BindingIDs of ReferenceParts in appearance +// order (CHT-COD-2). +var PartsExtractor extension.BindingExtractor = extension.BindingExtractorFunc(func(value any) ([]artifact.BindingID, error) { + var parts Parts + switch v := value.(type) { + case AssistantPayload: + parts = v.Assistant.Parts + case ToolResultPayload: + parts = v.ToolResult.Parts + case SummaryPayload: + parts = v.Summary.Parts + default: + return nil, fmt.Errorf("parts extractor: unexpected %T", value) + } + var out []artifact.BindingID + for _, p := range parts { + if ref, ok := p.(ReferencePart); ok { + out = append(out, ref.BindingID) + } + } + return out, nil +}) + +var partsBinding = extension.BindingReferenceDefinition{ + Extractor: PartsExtractor, + Cardinality: extension.Cardinality{Min: 0}, + RequiredDurability: artifact.EventBound, +} + +func def[T any](typ session.EventType, check func(*T) error, bindings ...extension.BindingReferenceDefinition) extension.EventDefinition { + return extension.EventDefinition{Type: typ, Current: 1, + Codecs: map[extension.PayloadVersion]extension.PayloadCodec{1: extension.JSONCodec[T]{Check: check}}, + Bindings: bindings} +} + +// Module is the chatlog ModuleDescriptor (CHT-SCP-1: no Requires). +var Module = extension.ModuleDescriptor{ + Source: extension.SourceTwilight, + ID: ModuleID, + Events: []extension.EventDefinition{ + def[InputSubmittedPayload](TypeInputSubmitted, func(p *InputSubmittedPayload) error { + if p.InputID == "" || p.Content.IsZero() { + return errors.New("input_submitted requires inputId and content") + } + return nil + }), + def[InputDeliveredPayload](TypeInputDelivered, func(p *InputDeliveredPayload) error { + if p.InputID == "" || p.TurnID == "" { + return errors.New("input_delivered requires inputId and turnId") + } + return nil + }), + def[InputWithdrawnPayload](TypeInputWithdrawn, nil), + def[InputRejectedPayload](TypeInputRejected, nil), + def[AssistantPayload](TypeAssistant, checkAssistant, partsBinding), + def[ToolResultPayload](TypeToolResult, checkToolResult, partsBinding), + def[ToolResultSupersededPayload](TypeToolResultSuperseded, nil), + def[SummaryPayload](TypeSummary, checkSummary, partsBinding), + def[CheckpointCreatedPayload](TypeCheckpointCreated, checkCheckpointCreated), + def[CheckpointInvalidatedPayload](TypeCheckpointInvalidated, checkCheckpointInvalidated), + }, + Projections: []extension.ProjectionDefinition{SurfaceProjection, ContextProjection}, +} diff --git a/agent/session/chatlog/chatlog_test.go b/agent/session/chatlog/chatlog_test.go new file mode 100644 index 0000000..6be2979 --- /dev/null +++ b/agent/session/chatlog/chatlog_test.go @@ -0,0 +1,386 @@ +package chatlog + +import ( + "testing" + + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/extension" +) + +func registry(t *testing.T) *extension.Registry { + t.Helper() + r, err := extension.BuildRegistry(session.ProtocolVersion1, Module) + if err != nil { + t.Fatal(err) + } + return r +} + +// Every part kind round-trips through the discriminated wire; foreign fields +// and unknown kinds are rejected (CHT-COD-1). +func TestPartsCodecRoundTripAndRejects(t *testing.T) { + parts := Parts{ + ReasoningPart{Text: "think"}, + TextPart{Text: "hello"}, + ToolCallPart{CallID: "c1", ProviderCallID: "call_x", Name: "lookup", Input: jsonstable.MustParse(`{"q":1}`)}, + ReferencePart{BindingID: "b1", Name: "file.txt"}, + } + raw, err := parts.MarshalJSON() + if err != nil { + t.Fatal(err) + } + var back Parts + if err := back.UnmarshalJSON(raw); err != nil { + t.Fatalf("%v\n%s", err, raw) + } + again, _ := back.MarshalJSON() + if string(again) != string(raw) { + t.Fatalf("round trip differs:\n%s\n%s", raw, again) + } + for name, wire := range map[string]string{ + "unknown kind": `[{"kind":"twilight/chatlog/video"}]`, + "text with call id": `[{"kind":"twilight/chatlog/text","text":"x","callId":"c"}]`, + "tool call without input": `[{"kind":"twilight/chatlog/tool_call","callId":"c","name":"n"}]`, + "reference without id": `[{"kind":"twilight/chatlog/reference","name":"f"}]`, + "unknown field": `[{"kind":"twilight/chatlog/text","text":"x","extra":1}]`, + } { + var p Parts + if err := p.UnmarshalJSON([]byte(wire)); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +// The assistant codec rejects a payload whose digest does not cover its parts; +// the Registry adds v and decodes back to the same value. +func TestAssistantDigestIsVerified(t *testing.T) { + r := registry(t) + a := Assistant{ID: "a1", TurnID: "t1", Parts: Parts{TextPart{Text: "hi"}}, SourceDigest: "sha256:src"} + d, err := DigestAssistant(&a) + if err != nil { + t.Fatal(err) + } + a.Digest = d + wire, _, err := r.Encode(TypeAssistant, AssistantPayload{Assistant: a}) + if err != nil { + t.Fatal(err) + } + decoded, err := r.Decode(session.SessionEvent{Type: TypeAssistant, Payload: wire}) + if err != nil || decoded.Value.(AssistantPayload).Assistant.Digest != d { + t.Fatalf("decode = %+v %v", decoded, err) + } + a.Parts = Parts{TextPart{Text: "changed"}} + if _, _, err := r.Encode(TypeAssistant, AssistantPayload{Assistant: a}); err == nil { + t.Fatal("digest mismatch accepted") + } + if ids, _ := PartsExtractor.BindingIDs(AssistantPayload{Assistant: Assistant{Parts: Parts{ReferencePart{BindingID: "b2"}, TextPart{}, ReferencePart{BindingID: "b1"}}}}); len(ids) != 2 || ids[0] != "b2" { + t.Fatalf("extractor = %v", ids) + } +} + +// Surface and Context agree: only delivered inputs enter the context, in +// stream order with assistant and tool_result entries; a superseded tool +// result leaves the context. +func TestSurfaceAndContextFold(t *testing.T) { + r := registry(t) + content := jsonstable.MustParse(`{"text":"hi"}`) + tr := ToolResult{ID: "r1", TurnID: "t1", CallID: "c1", Status: ToolUnknown, Parts: Parts{TextPart{Text: "lost"}}} + tr.Digest, _ = DigestToolResult(&tr) + tr2 := ToolResult{ID: "r2", TurnID: "t1", CallID: "c1", Status: ToolSuccess, Parts: Parts{TextPart{Text: "ok"}}} + tr2.Digest, _ = DigestToolResult(&tr2) + events := []struct { + typ session.EventType + value any + }{ + {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: content, SubmittedAtUnixMilli: 1}}, + {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-2", Content: content, SubmittedAtUnixMilli: 2}}, + {TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1"}}, + {TypeToolResult, ToolResultPayload{ToolResult: tr}}, + {TypeToolResult, ToolResultPayload{ToolResult: tr2}}, + {TypeToolResultSuperseded, ToolResultSupersededPayload{ToolResultID: "r1", ReplacementToolResultID: "r2"}}, + } + var decoded []extension.DecodedEvent + for i, e := range events { + wire, _, err := r.Encode(e.typ, e.value) + if err != nil { + t.Fatalf("event %d: %v", i, err) + } + d, err := r.Decode(session.SessionEvent{Type: e.typ, Payload: wire, Index: uint16(i)}) + if err != nil { + t.Fatal(err) + } + decoded = append(decoded, d) + } + surfaceState, _ := SurfaceProjection.Initial() + contextState, _ := ContextProjection.Initial() + for _, d := range decoded { + var err error + if surfaceState, err = SurfaceProjection.Apply(surfaceState, d); err != nil { + t.Fatal(err) + } + if contextState, err = ContextProjection.Apply(contextState, d); err != nil { + t.Fatal(err) + } + } + surface := surfaceState.(Surface) + if surface.Inputs["in-1"].Status != InputDelivered || surface.Inputs["in-2"].Status != InputSubmitted { + t.Fatalf("inputs = %+v", surface.Inputs) + } + if pending := surface.SubmittedInputs(); len(pending) != 1 || pending[0].ID != "in-2" { + t.Fatalf("submitted = %+v", pending) + } + if len(surface.EntryOrder) != 3 || surface.Superseded["r1"] != "r2" { + t.Fatalf("surface = %+v", surface) + } + entries := contextState.(Context).Entries + if len(entries) != 2 || entries[0].Kind != EntryInput || entries[1].ID != "r2" { + t.Fatalf("context = %+v", entries) + } + folded, err := ContextFold(decoded) + if err != nil || len(folded) != 2 { + t.Fatalf("ContextFold = %+v %v", folded, err) + } + // Delivering an input twice is a reducer error (CHT-EVT-2). + if _, err := SurfaceProjection.Apply(surfaceState, decoded[2]); err == nil { + t.Fatal("second delivery accepted") + } +} + +// EXT-COD-1: every registered event type's current codec is canonical +// round-trip stable — Encode, Decode, Encode reproduces the bytes. +func TestEventCodecCanonicalRoundTrip(t *testing.T) { + assistant := Assistant{ID: "a1", TurnID: "t1", Parts: Parts{TextPart{Text: "hi"}, ReferencePart{BindingID: "b1"}}, SourceDigest: "sha256:src"} + var err error + if assistant.Digest, err = DigestAssistant(&assistant); err != nil { + t.Fatal(err) + } + toolResult := ToolResult{ID: "tr1", TurnID: "t1", CallID: "c1", Status: ToolSuccess, Parts: Parts{TextPart{Text: "ok"}}, SourceDigest: "sha256:out"} + if toolResult.Digest, err = DigestToolResult(&toolResult); err != nil { + t.Fatal(err) + } + summary := Summary{ID: "sum1", Parts: Parts{TextPart{Text: "so far"}}} + if summary.Digest, err = DigestSummary(&summary); err != nil { + t.Fatal(err) + } + checkpoint := CheckpointCreatedPayload{CheckpointID: "ck1", CoveredThrough: 3, BaseContextDigest: "sha256:base", + SummaryID: summary.ID, SummaryDigest: summary.Digest, + Retained: []EntryDigestPair{{Kind: EntryAssistant, ID: "a1", Digest: assistant.Digest}}} + if checkpoint.Digest, err = DigestCheckpoint(&checkpoint); err != nil { + t.Fatal(err) + } + samples := map[session.EventType]any{ + TypeInputSubmitted: InputSubmittedPayload{InputID: "in-1", Content: jsonstable.MustParse(`{"text":"hi"}`), SubmittedAtUnixMilli: 1}, + TypeInputDelivered: InputDeliveredPayload{InputID: "in-1", TurnID: "t1"}, + TypeInputWithdrawn: InputWithdrawnPayload{InputID: "in-1", Reason: "user"}, + TypeInputRejected: InputRejectedPayload{InputID: "in-1"}, + TypeAssistant: AssistantPayload{Assistant: assistant}, + TypeToolResult: ToolResultPayload{ToolResult: toolResult}, + TypeToolResultSuperseded: ToolResultSupersededPayload{ToolResultID: "tr1", ReplacementToolResultID: "tr2"}, + TypeSummary: SummaryPayload{Summary: summary}, + TypeCheckpointCreated: checkpoint, + TypeCheckpointInvalidated: CheckpointInvalidatedPayload{CheckpointID: "ck1", Reason: "host"}, + } + for _, def := range Module.Events { + value, ok := samples[def.Type] + if !ok { + t.Fatalf("no sample for %s", def.Type) + } + codec := def.Codecs[def.Current] + first, err := codec.Encode(value) + if err != nil { + t.Fatalf("%s: encode: %v", def.Type, err) + } + back, err := codec.Decode(first) + if err != nil { + t.Fatalf("%s: decode: %v", def.Type, err) + } + again, err := codec.Encode(back) + if err != nil || !again.Equal(first) { + t.Fatalf("%s: round trip changed bytes: %s vs %s (%v)", def.Type, first, again, err) + } + } +} + +// --- checkpoint fold (CHT-EVT-3, CHT-CTX-2, CHT-SUR-1) -------------------------- + +type step struct { + typ session.EventType + value any +} + +// foldSteps encodes, decodes and folds steps through both projections, +// returning the states and the first fold error. +func foldSteps(t *testing.T, steps []step) (Context, Surface, error) { + t.Helper() + r := registry(t) + surfaceState, _ := SurfaceProjection.Initial() + contextState, _ := ContextProjection.Initial() + for i, st := range steps { + wire, _, err := r.Encode(st.typ, st.value) + if err != nil { + t.Fatalf("step %d encode: %v", i, err) + } + d, err := r.Decode(session.SessionEvent{Type: st.typ, Payload: wire, Seq: session.Seq(i)}) + if err != nil { + t.Fatal(err) + } + nextSurface, err := SurfaceProjection.Apply(surfaceState, d) + if err != nil { + return contextState.(Context), surfaceState.(Surface), err + } + nextContext, err := ContextProjection.Apply(contextState, d) + if err != nil { + return contextState.(Context), nextSurface.(Surface), err + } + surfaceState, contextState = nextSurface, nextContext + } + return contextState.(Context), surfaceState.(Surface), nil +} + +func mustSummary(t *testing.T, id SummaryID, text string) Summary { + t.Helper() + s := Summary{ID: id, Parts: Parts{TextPart{Text: text}}} + var err error + if s.Digest, err = DigestSummary(&s); err != nil { + t.Fatal(err) + } + return s +} + +func mustAssistant(t *testing.T, id AssistantID, parts Parts) Assistant { + t.Helper() + a := Assistant{ID: id, TurnID: "t1", Parts: parts} + var err error + if a.Digest, err = DigestAssistant(&a); err != nil { + t.Fatal(err) + } + return a +} + +func mustCheckpoint(t *testing.T, id CheckpointID, covered session.Seq, base []EntryDigestPair, sum Summary, retained []EntryDigestPair) CheckpointCreatedPayload { + t.Helper() + baseDigest, err := DigestBaseContext(base) + if err != nil { + t.Fatal(err) + } + p := CheckpointCreatedPayload{CheckpointID: id, CoveredThrough: covered, BaseContextDigest: baseDigest, + SummaryID: sum.ID, SummaryDigest: sum.Digest, Retained: retained} + if p.Digest, err = DigestCheckpoint(&p); err != nil { + t.Fatal(err) + } + return p +} + +func TestCheckpointFold(t *testing.T) { + content := jsonstable.MustParse(`{"text":"hi"}`) + inDigest, err := DigestInput("in-1", content) + if err != nil { + t.Fatal(err) + } + a1 := mustAssistant(t, "a1", Parts{TextPart{Text: "one"}}) + sum := mustSummary(t, "sum1", "so far") + base := []EntryDigestPair{{Kind: EntryInput, ID: "in-1", Digest: inDigest}, {Kind: EntryAssistant, ID: "a1", Digest: a1.Digest}} + // Steps 0..4: delivered input (entry seq 1), assistant (seq 2), a queued + // input that must survive compaction, the summary (seq 4). + prefix := []step{ + {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-1", Content: content, SubmittedAtUnixMilli: 1}}, + {TypeInputDelivered, InputDeliveredPayload{InputID: "in-1", TurnID: "t1"}}, + {TypeAssistant, AssistantPayload{Assistant: a1}}, + {TypeInputSubmitted, InputSubmittedPayload{InputID: "in-q", Content: content, SubmittedAtUnixMilli: 2}}, + {TypeSummary, SummaryPayload{Summary: sum}}, + } + valid := mustCheckpoint(t, "ck1", 3, base, sum, base[1:]) + + t.Run("valid checkpoint replaces the base and keeps the queue", func(t *testing.T) { + a2 := mustAssistant(t, "a2", Parts{TextPart{Text: "after"}}) + ctxState, surf, err := foldSteps(t, append(prefix, step{TypeCheckpointCreated, valid}, step{TypeAssistant, AssistantPayload{Assistant: a2}})) + if err != nil { + t.Fatal(err) + } + entries := ctxState.Entries + if len(entries) != 3 || entries[0].Kind != EntrySummary || entries[1].ID != "a1" || entries[2].ID != "a2" { + t.Fatalf("entries = %+v", entries) + } + if _, pending := ctxState.Pending["in-q"]; !pending { + t.Fatal("queued input compacted away") + } + if got := surf.SubmittedInputs(); len(got) != 1 || got[0].ID != "in-q" { + t.Fatalf("surface queue = %+v", got) + } + if v := surf.Checkpoints["ck1"]; v.Status != CheckpointActive { + t.Fatalf("surface checkpoint = %+v", v) + } + if len(surf.EntryOrder) != 4 { // full history stays visible + t.Fatalf("entry order = %+v", surf.EntryOrder) + } + }) + + t.Run("invalidating the latest checkpoint restores base plus tail", func(t *testing.T) { + a2 := mustAssistant(t, "a2", Parts{TextPart{Text: "after"}}) + ctxState, surf, err := foldSteps(t, append(prefix, + step{TypeCheckpointCreated, valid}, + step{TypeAssistant, AssistantPayload{Assistant: a2}}, + step{TypeCheckpointInvalidated, CheckpointInvalidatedPayload{CheckpointID: "ck1", Reason: "host"}})) + if err != nil { + t.Fatal(err) + } + entries := ctxState.Entries + if len(entries) != 3 || entries[0].ID != "in-1" || entries[1].ID != "a1" || entries[2].ID != "a2" { + t.Fatalf("restored entries = %+v", entries) + } + if len(ctxState.Checkpoints) != 0 { + t.Fatalf("checkpoint stack = %+v", ctxState.Checkpoints) + } + if v := surf.Checkpoints["ck1"]; v.Status != CheckpointInvalidated || v.Reason != "host" { + t.Fatalf("surface checkpoint = %+v", v) + } + }) + + rejects := []struct { + name string + steps []step + }{ + {"covered through at or past the checkpoint row", + append(prefix, step{TypeCheckpointCreated, mustCheckpoint(t, "ck2", 5, base, sum, nil)})}, + {"base context digest mismatch", + append(prefix, step{TypeCheckpointCreated, mustCheckpoint(t, "ck3", 3, base[:1], sum, nil)})}, + {"retained outside the base", + append(prefix, step{TypeCheckpointCreated, mustCheckpoint(t, "ck4", 3, base, + sum, []EntryDigestPair{{Kind: EntryAssistant, ID: "a1", Digest: "sha256:wrong"}})})}, + {"gap holds more than the summary", + append(append([]step{}, prefix...), step{TypeAssistant, AssistantPayload{Assistant: mustAssistant(t, "a9", Parts{TextPart{Text: "x"}})}}, + step{TypeCheckpointCreated, mustCheckpoint(t, "ck5", 3, + append(base, EntryDigestPair{Kind: EntryAssistant, ID: "a9"}), sum, nil)})}, + {"invalidating an unknown checkpoint", + append(prefix, step{TypeCheckpointInvalidated, CheckpointInvalidatedPayload{CheckpointID: "nope"}})}, + } + for _, tc := range rejects { + t.Run(tc.name, func(t *testing.T) { + if _, _, err := foldSteps(t, tc.steps); err == nil { + t.Fatal("fold accepted") + } + }) + } + + t.Run("superseding a compacted result is rejected", func(t *testing.T) { + call := Parts{ToolCallPart{CallID: "c1", Name: "lookup", Input: jsonstable.MustParse(`{}`)}} + aCall := mustAssistant(t, "ac", call) + r1 := ToolResult{ID: "r1", TurnID: "t1", CallID: "c1", Status: ToolSuccess, Parts: Parts{TextPart{Text: "ok"}}} + if r1.Digest, err = DigestToolResult(&r1); err != nil { + t.Fatal(err) + } + toolBase := []EntryDigestPair{{Kind: EntryAssistant, ID: "ac", Digest: aCall.Digest}, {Kind: EntryToolResult, ID: "r1", Digest: r1.Digest}} + sum2 := mustSummary(t, "sum2", "tools done") + steps := []step{ + {TypeAssistant, AssistantPayload{Assistant: aCall}}, + {TypeToolResult, ToolResultPayload{ToolResult: r1}}, + {TypeSummary, SummaryPayload{Summary: sum2}}, + {TypeCheckpointCreated, mustCheckpoint(t, "ck6", 1, toolBase, sum2, nil)}, + {TypeToolResultSuperseded, ToolResultSupersededPayload{ToolResultID: "r1", ReplacementToolResultID: "r2"}}, + } + if _, _, err := foldSteps(t, steps); err == nil { + t.Fatal("supersede of a compacted result accepted") + } + }) +} diff --git a/agent/session/chatlog/projection.go b/agent/session/chatlog/projection.go new file mode 100644 index 0000000..a411662 --- /dev/null +++ b/agent/session/chatlog/projection.go @@ -0,0 +1,447 @@ +package chatlog + +import ( + "errors" + "fmt" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/extension" +) + +const ( + SurfaceProjectionID extension.ProjectionID = "twilight/chatlog/surface" + ContextProjectionID extension.ProjectionID = "twilight/chatlog/context" +) + +type InputStatus string + +const ( + InputSubmitted InputStatus = "submitted" + InputDelivered InputStatus = "delivered" + InputWithdrawn InputStatus = "withdrawn" + InputRejected InputStatus = "rejected" +) + +type InputView struct { + Input Input `json:"input"` + Status InputStatus `json:"status"` + // Seq orders inputs by submission within the stream. + Seq uint64 `json:"seq"` +} + +type EntryKind string + +const ( + EntryInput EntryKind = "input" + EntryAssistant EntryKind = "assistant" + EntryToolResult EntryKind = "tool_result" + EntrySummary EntryKind = "summary" +) + +type SurfaceEntry struct { + Kind EntryKind `json:"kind"` + ID string `json:"id"` + Seq session.Seq `json:"seq"` +} + +type CheckpointStatus string + +const ( + CheckpointActive CheckpointStatus = "active" + CheckpointInvalidated CheckpointStatus = "invalidated" +) + +// CheckpointView records one checkpoint for readers; compaction never touches +// EntryOrder or the input queue (CHT-SUR-1). +type CheckpointView struct { + Checkpoint CheckpointCreatedPayload `json:"checkpoint"` + Status CheckpointStatus `json:"status"` + Reason string `json:"reason,omitempty"` + Seq session.Seq `json:"seq"` +} + +// Surface is the UI-facing read model (CHT-SUR-1). +type Surface struct { + Inputs map[InputID]InputView `json:"inputs"` + Assistants map[AssistantID]Assistant `json:"assistants"` + ToolResults map[ToolResultID]ToolResult `json:"toolResults"` + Summaries map[SummaryID]Summary `json:"summaries"` + EntryOrder []SurfaceEntry `json:"entryOrder"` + Superseded map[ToolResultID]ToolResultID `json:"superseded,omitempty"` + Checkpoints map[CheckpointID]CheckpointView `json:"checkpoints,omitempty"` + nextSeq uint64 +} + +// SubmittedInputs returns inputs still awaiting delivery, in submission order. +func (s *Surface) SubmittedInputs() []Input { + var out []InputView + for _, v := range s.Inputs { + if v.Status == InputSubmitted { + out = append(out, v) + } + } + sortViews(out) + inputs := make([]Input, len(out)) + for i := range out { + inputs[i] = out[i].Input + } + return inputs +} + +func sortViews(views []InputView) { + for i := 1; i < len(views); i++ { + for j := i; j > 0 && views[j].Seq < views[j-1].Seq; j-- { + views[j], views[j-1] = views[j-1], views[j] + } + } +} + +var chatlogConsumes = []session.EventType{TypeInputSubmitted, TypeInputDelivered, TypeInputWithdrawn, TypeInputRejected, + TypeAssistant, TypeToolResult, TypeToolResultSuperseded, TypeSummary, TypeCheckpointCreated, TypeCheckpointInvalidated} + +var SurfaceProjection = extension.ProjectionDefinition{ + ID: SurfaceProjectionID, Version: 1, + Consumes: chatlogConsumes, + Initial: func() (any, error) { + return Surface{Inputs: map[InputID]InputView{}, Assistants: map[AssistantID]Assistant{}, ToolResults: map[ToolResultID]ToolResult{}, Summaries: map[SummaryID]Summary{}, Superseded: map[ToolResultID]ToolResultID{}, Checkpoints: map[CheckpointID]CheckpointView{}}, nil + }, + Apply: applySurface, + StateCodec: extension.JSONStateCodec[Surface]{}, +} + +func applySurface(state any, e extension.DecodedEvent) (any, error) { + s := state.(Surface) + s = cloneSurface(s) + pos := e.Event.Seq + switch p := e.Value.(type) { + case InputSubmittedPayload: + if _, dup := s.Inputs[p.InputID]; dup { + return nil, fmt.Errorf("input %s submitted twice", p.InputID) + } + d, err := DigestInput(p.InputID, p.Content) + if err != nil { + return nil, err + } + s.nextSeq++ + s.Inputs[p.InputID] = InputView{Input: Input{ID: p.InputID, Content: p.Content, Digest: d}, Status: InputSubmitted, Seq: s.nextSeq} + case InputDeliveredPayload: + v, ok := s.Inputs[p.InputID] + if !ok || v.Status != InputSubmitted { + return nil, fmt.Errorf("input %s delivered while %s", p.InputID, v.Status) + } + v.Status = InputDelivered + v.Input.TurnID = p.TurnID + s.Inputs[p.InputID] = v + s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntryInput, ID: string(p.InputID), Seq: pos}) + case InputWithdrawnPayload: + if err := terminateInput(&s, p.InputID, InputWithdrawn); err != nil { + return nil, err + } + case InputRejectedPayload: + if err := terminateInput(&s, p.InputID, InputRejected); err != nil { + return nil, err + } + case AssistantPayload: + if _, dup := s.Assistants[p.Assistant.ID]; dup { + return nil, fmt.Errorf("assistant %s created twice", p.Assistant.ID) + } + s.Assistants[p.Assistant.ID] = p.Assistant + s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntryAssistant, ID: string(p.Assistant.ID), Seq: pos}) + case ToolResultPayload: + if _, dup := s.ToolResults[p.ToolResult.ID]; dup { + return nil, fmt.Errorf("tool_result %s created twice", p.ToolResult.ID) + } + s.ToolResults[p.ToolResult.ID] = p.ToolResult + s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntryToolResult, ID: string(p.ToolResult.ID), Seq: pos}) + case ToolResultSupersededPayload: + if _, ok := s.ToolResults[p.ToolResultID]; !ok { + return nil, fmt.Errorf("superseded tool_result %s unknown", p.ToolResultID) + } + if _, dup := s.Superseded[p.ToolResultID]; dup { + return nil, fmt.Errorf("tool_result %s superseded twice", p.ToolResultID) + } + s.Superseded[p.ToolResultID] = p.ReplacementToolResultID + case SummaryPayload: + if _, dup := s.Summaries[p.Summary.ID]; dup { + return nil, fmt.Errorf("summary %s created twice", p.Summary.ID) + } + s.Summaries[p.Summary.ID] = p.Summary + s.EntryOrder = append(s.EntryOrder, SurfaceEntry{Kind: EntrySummary, ID: string(p.Summary.ID), Seq: pos}) + case CheckpointCreatedPayload: + if _, dup := s.Checkpoints[p.CheckpointID]; dup { + return nil, fmt.Errorf("checkpoint %s created twice", p.CheckpointID) + } + sum, ok := s.Summaries[p.SummaryID] + if !ok || sum.Digest != p.SummaryDigest { + return nil, fmt.Errorf("checkpoint %s names summary %s which does not match", p.CheckpointID, p.SummaryID) + } + s.Checkpoints[p.CheckpointID] = CheckpointView{Checkpoint: p, Status: CheckpointActive, Seq: pos} + case CheckpointInvalidatedPayload: + v, ok := s.Checkpoints[p.CheckpointID] + if !ok || v.Status != CheckpointActive { + return nil, fmt.Errorf("checkpoint %s invalidated while not active", p.CheckpointID) + } + v.Status = CheckpointInvalidated + v.Reason = p.Reason + s.Checkpoints[p.CheckpointID] = v + default: + return nil, fmt.Errorf("chatlog surface: unexpected %T", e.Value) + } + return s, nil +} + +func terminateInput(s *Surface, id InputID, status InputStatus) error { + v, ok := s.Inputs[id] + if !ok || v.Status != InputSubmitted { + return fmt.Errorf("input %s %s while %s", id, status, v.Status) + } + v.Status = status + s.Inputs[id] = v + return nil +} + +func cloneSurface(s Surface) Surface { + out := Surface{Inputs: make(map[InputID]InputView, len(s.Inputs)), Assistants: make(map[AssistantID]Assistant, len(s.Assistants)), + ToolResults: make(map[ToolResultID]ToolResult, len(s.ToolResults)), Summaries: make(map[SummaryID]Summary, len(s.Summaries)), + Superseded: make(map[ToolResultID]ToolResultID, len(s.Superseded)), Checkpoints: make(map[CheckpointID]CheckpointView, len(s.Checkpoints)), + EntryOrder: append([]SurfaceEntry(nil), s.EntryOrder...), nextSeq: s.nextSeq} + for k, v := range s.Checkpoints { + out.Checkpoints[k] = v + } + for k, v := range s.Inputs { + out.Inputs[k] = v + } + for k, v := range s.Assistants { + out.Assistants[k] = v + } + for k, v := range s.ToolResults { + out.ToolResults[k] = v + } + for k, v := range s.Summaries { + out.Summaries[k] = v + } + for k, v := range s.Superseded { + out.Superseded[k] = v + } + if out.nextSeq == 0 { + // Restored from a snapshot: the counter is not persisted, but Seq only + // has to be monotonic, so continue from the largest known value. + for _, v := range out.Inputs { + if v.Seq > out.nextSeq { + out.nextSeq = v.Seq + } + } + } + return out +} + +// --- context ------------------------------------------------------------------ + +// Entry is one element of the model-facing conversation (CHT-CTX-1). Seq is +// the stream row that folded the entry in; checkpoints split base from gap by +// it (CHT-EVT-3). +type Entry struct { + Kind EntryKind `json:"kind"` + ID string `json:"id"` + Digest es.Digest `json:"digest"` + Seq session.Seq `json:"seq"` + Input *Input `json:"input,omitempty"` + Assistant *Assistant `json:"assistant,omitempty"` + ToolResult *ToolResult `json:"toolResult,omitempty"` + Summary *Summary `json:"summary,omitempty"` +} + +// Pair names the entry for checkpoint base and retained sets. +func (e *Entry) Pair() EntryDigestPair { + return EntryDigestPair{Kind: e.Kind, ID: e.ID, Digest: e.Digest} +} + +// AppliedCheckpoint archives what a checkpoint replaced so an explicit +// invalidation restores it (CHT-EVT-3). Base excludes the checkpoint's own +// summary entry: invalidation drops the summary from the active context. +type AppliedCheckpoint struct { + ID CheckpointID `json:"id"` + // Base is the active context the checkpoint covered, in order. + Base []Entry `json:"base"` + // PrefixLen is what the checkpoint contributed to Entries: the summary + // plus the retained entries. + PrefixLen int `json:"prefixLen"` +} + +// Context is the projection state: the ordered entries plus the bookkeeping +// ContextFold needs (submitted inputs awaiting delivery, superseded results, +// applied checkpoints). +type Context struct { + Entries []Entry `json:"entries"` + Pending map[InputID]Input `json:"pending,omitempty"` + Superseded map[ToolResultID]ToolResultID `json:"superseded,omitempty"` + Checkpoints []AppliedCheckpoint `json:"checkpoints,omitempty"` +} + +var ContextProjection = extension.ProjectionDefinition{ + ID: ContextProjectionID, Version: 1, + Consumes: chatlogConsumes, + Initial: func() (any, error) { + return Context{Pending: map[InputID]Input{}, Superseded: map[ToolResultID]ToolResultID{}}, nil + }, + Apply: applyContext, + StateCodec: extension.JSONStateCodec[Context]{}, +} + +func applyContext(state any, e extension.DecodedEvent) (any, error) { + c := state.(Context) + c = Context{Entries: append([]Entry(nil), c.Entries...), Pending: copyInputs(c.Pending), Superseded: copyIDs(c.Superseded), + Checkpoints: append([]AppliedCheckpoint(nil), c.Checkpoints...)} + pos := e.Event.Seq + switch p := e.Value.(type) { + case InputSubmittedPayload: + d, err := DigestInput(p.InputID, p.Content) + if err != nil { + return nil, err + } + c.Pending[p.InputID] = Input{ID: p.InputID, Content: p.Content, Digest: d} + case InputDeliveredPayload: + in, ok := c.Pending[p.InputID] + if !ok { + return nil, fmt.Errorf("input %s delivered before submission", p.InputID) + } + delete(c.Pending, p.InputID) + in.TurnID = p.TurnID + c.Entries = append(c.Entries, Entry{Kind: EntryInput, ID: string(in.ID), Digest: in.Digest, Seq: pos, Input: &in}) + case InputWithdrawnPayload: + delete(c.Pending, p.InputID) + case InputRejectedPayload: + delete(c.Pending, p.InputID) + case AssistantPayload: + a := p.Assistant + c.Entries = append(c.Entries, Entry{Kind: EntryAssistant, ID: string(a.ID), Digest: a.Digest, Seq: pos, Assistant: &a}) + case ToolResultPayload: + r := p.ToolResult + c.Entries = append(c.Entries, Entry{Kind: EntryToolResult, ID: string(r.ID), Digest: r.Digest, Seq: pos, ToolResult: &r}) + case ToolResultSupersededPayload: + c.Superseded[p.ToolResultID] = p.ReplacementToolResultID + kept := c.Entries[:0:0] + found := false + for _, en := range c.Entries { + if en.Kind == EntryToolResult && en.ID == string(p.ToolResultID) { + found = true + continue + } + kept = append(kept, en) + } + if !found { + // A result outside the active context was either never created or + // compacted; its Turn completed, so superseding it violates + // CHT-ENT-2 rather than invalidating the checkpoint. + return nil, fmt.Errorf("tool_result %s superseded outside the active context", p.ToolResultID) + } + c.Entries = kept + case SummaryPayload: + s := p.Summary + c.Entries = append(c.Entries, Entry{Kind: EntrySummary, ID: string(s.ID), Digest: s.Digest, Seq: pos, Summary: &s}) + case CheckpointCreatedPayload: + return applyCheckpoint(c, &p, pos) + case CheckpointInvalidatedPayload: + n := len(c.Checkpoints) + if n == 0 || c.Checkpoints[n-1].ID != p.CheckpointID { + return nil, fmt.Errorf("checkpoint %s is not the latest active checkpoint", p.CheckpointID) + } + top := c.Checkpoints[n-1] + if len(c.Entries) < top.PrefixLen { + return nil, fmt.Errorf("checkpoint %s prefix exceeds the context", p.CheckpointID) + } + c.Entries = append(append([]Entry(nil), top.Base...), c.Entries[top.PrefixLen:]...) + c.Checkpoints = c.Checkpoints[:n-1] + default: + return nil, fmt.Errorf("chatlog context: unexpected %T", e.Value) + } + return c, nil +} + +// applyCheckpoint validates and applies one checkpoint_created (CHT-EVT-3). +func applyCheckpoint(c Context, p *CheckpointCreatedPayload, pos session.Seq) (any, error) { + if p.CoveredThrough >= pos { + return nil, fmt.Errorf("checkpoint %s covers through %d at row %d", p.CheckpointID, p.CoveredThrough, pos) + } + for _, ap := range c.Checkpoints { + if ap.ID == p.CheckpointID { + return nil, fmt.Errorf("checkpoint %s created twice", p.CheckpointID) + } + } + cut := len(c.Entries) + for cut > 0 && c.Entries[cut-1].Seq > p.CoveredThrough { + cut-- + } + base, gap := c.Entries[:cut], c.Entries[cut:] + if len(gap) != 1 || gap[0].Kind != EntrySummary || gap[0].ID != string(p.SummaryID) || gap[0].Digest != p.SummaryDigest { + return nil, fmt.Errorf("checkpoint %s: the entries after coveredThrough must be exactly its summary", p.CheckpointID) + } + pairs := make([]EntryDigestPair, len(base)) + for i := range base { + pairs[i] = base[i].Pair() + } + wantBase, err := DigestBaseContext(pairs) + if err != nil { + return nil, err + } + if wantBase != p.BaseContextDigest { + return nil, fmt.Errorf("checkpoint %s: base context digest mismatch", p.CheckpointID) + } + retained, err := selectRetained(base, p.Retained) + if err != nil { + return nil, fmt.Errorf("checkpoint %s: %w", p.CheckpointID, err) + } + c.Checkpoints = append(c.Checkpoints, AppliedCheckpoint{ID: p.CheckpointID, Base: base, PrefixLen: 1 + len(retained)}) + c.Entries = append([]Entry{gap[0]}, retained...) + return c, nil +} + +// selectRetained resolves the retained pairs as an ordered subset of base. +func selectRetained(base []Entry, pairs []EntryDigestPair) ([]Entry, error) { + out := make([]Entry, 0, len(pairs)) + i := 0 + for _, p := range pairs { + for i < len(base) && base[i].Pair() != p { + i++ + } + if i == len(base) { + return nil, fmt.Errorf("retained %s %s is not in the base context in order", p.Kind, p.ID) + } + out = append(out, base[i]) + i++ + } + return out, nil +} + +func copyInputs(m map[InputID]Input) map[InputID]Input { + out := make(map[InputID]Input, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func copyIDs(m map[ToolResultID]ToolResultID) map[ToolResultID]ToolResultID { + out := make(map[ToolResultID]ToolResultID, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// ContextFold folds decoded chatlog events into entries (CHT-CTX-1). +func ContextFold(events []extension.DecodedEvent) ([]Entry, error) { + state, _ := ContextProjection.Initial() + for _, e := range events { + if e.Module != extension.TwilightModule(ModuleID) || e.Unknown { + return nil, errors.New("chatlog: context fold requires decoded chatlog events") + } + next, err := applyContext(state, e) + if err != nil { + return nil, err + } + state = next + } + return state.(Context).Entries, nil +} diff --git a/agent/session/extension/binding.go b/agent/session/extension/binding.go new file mode 100644 index 0000000..daf9b61 --- /dev/null +++ b/agent/session/extension/binding.go @@ -0,0 +1,45 @@ +package extension + +import ( + "errors" + + "github.com/felinics/twilight/agent/artifact" +) + +type Cardinality struct { + Min uint32 + Max *uint32 +} + +// BindingExtractor returns every Artifact reference inside a decoded typed +// value, in appearance order (EXT-REF-1). +type BindingExtractor interface { + BindingIDs(value any) ([]artifact.BindingID, error) +} + +// BindingExtractorFunc adapts a function to BindingExtractor. +type BindingExtractorFunc func(value any) ([]artifact.BindingID, error) + +func (f BindingExtractorFunc) BindingIDs(value any) ([]artifact.BindingID, error) { return f(value) } + +// BindingReferenceDefinition declares where an event may reference Artifacts +// and what admission requires of them (EXT-REF-2). +type BindingReferenceDefinition struct { + Extractor BindingExtractor + Cardinality Cardinality + AllowedSchemes []artifact.Scheme + RequiredDurability artifact.Durability +} + +func (d *BindingReferenceDefinition) validate() error { + if d.Extractor == nil { + return errors.New("binding declaration needs an Extractor") + } + if d.Cardinality.Max != nil && *d.Cardinality.Max < d.Cardinality.Min { + return errors.New("cardinality max below min") + } + if d.RequiredDurability.Rank() < artifact.EventBound.Rank() { + return errors.New("required durability must be at least event_bound") + } + return nil +} diff --git a/agent/session/extension/extension_test.go b/agent/session/extension/extension_test.go new file mode 100644 index 0000000..3121c03 --- /dev/null +++ b/agent/session/extension/extension_test.go @@ -0,0 +1,418 @@ +package extension + +import ( + "context" + "errors" + "testing" + + "github.com/felinics/twilight/agent/artifact" + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/session" +) + +type notePayload struct { + Text string `json:"text"` + Refs []string `json:"refs,omitempty"` +} + +type noteState struct { + Notes []string `json:"notes"` +} + +var refsExtractor = BindingExtractorFunc(func(value any) ([]artifact.BindingID, error) { + var out []artifact.BindingID + for _, r := range value.(notePayload).Refs { + out = append(out, artifact.BindingID(r)) + } + return out, nil +}) + +// tpfx is the first-party prefix of a test module. +func tpfx(id ModuleID) session.EventType { return ModulePrefix(SourceTwilight, id) } + +func noteModule(id ModuleID, requires ...ModuleRequirement) ModuleDescriptor { + typ := tpfx(id) + "note" + return ModuleDescriptor{Source: SourceTwilight, ID: id, Requires: requires, + Events: []EventDefinition{ + {Type: typ, Current: 1, Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}, + Bindings: []BindingReferenceDefinition{{Extractor: refsExtractor, RequiredDurability: artifact.EventBound}}}, + {Type: tpfx(id) + "hint", Current: 1, Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}, Ignorable: true}, + }, + Projections: []ProjectionDefinition{{ + ID: ProjectionID(string(typ) + "s"), Version: 1, Consumes: []session.EventType{typ}, + Initial: func() (any, error) { return noteState{}, nil }, + Apply: func(state any, e DecodedEvent) (any, error) { + s := state.(noteState) + text := e.Value.(notePayload).Text + if text == "reject" { + return nil, errors.New("rejected by projection") + } + s.Notes = append(append([]string(nil), s.Notes...), text) + return s, nil + }, + StateCodec: JSONStateCodec[noteState]{}, + }}, + } +} + +func TestBuildRegistryValidatesRequires(t *testing.T) { + cases := map[string][]ModuleDescriptor{ + "unregistered dependency": {noteModule("a", ModuleRequirement{Source: SourceTwilight, Module: "zzz"})}, + "cycle": {noteModule("a", ModuleRequirement{Source: SourceTwilight, Module: "b"}), noteModule("b", ModuleRequirement{Source: SourceTwilight, Module: "a"})}, + "unhandled version": {noteModule("a"), noteModule("b", ModuleRequirement{Source: SourceTwilight, Module: "a", + Events: map[session.EventType][]PayloadVersion{tpfx("a") + "note": {2}}})}, + "event outside module": {{Source: SourceTwilight, ID: "a", Events: []EventDefinition{{Type: "twilight/b/x", Current: 1, Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}}}}}, + "projection outside scope": {noteModule("a"), {Source: SourceTwilight, ID: "b", Projections: []ProjectionDefinition{{ID: "p", Version: 1, Consumes: []session.EventType{tpfx("a") + "note"}, + Initial: func() (any, error) { return nil, nil }, Apply: func(s any, _ DecodedEvent) (any, error) { return s, nil }, StateCodec: JSONStateCodec[noteState]{}}}}}, + } + for name, modules := range cases { + if _, err := BuildRegistry(session.ProtocolVersion1, modules...); err == nil { + t.Errorf("%s: registry built", name) + } + } + if _, err := BuildRegistry(session.ProtocolVersion1, noteModule("a"), noteModule("b", ModuleRequirement{Source: SourceTwilight, Module: "a", + Events: map[session.EventType][]PayloadVersion{tpfx("a") + "note": {1}}})); err != nil { + t.Fatalf("valid registry: %v", err) + } +} + +// srcModule is a minimal module under an arbitrary source. +func srcModule(source SourceID, id ModuleID) ModuleDescriptor { + return ModuleDescriptor{Source: source, ID: id, Events: []EventDefinition{{ + Type: ModulePrefix(source, id) + "note", Current: 1, + Codecs: map[PayloadVersion]PayloadCodec{1: JSONCodec[notePayload]{}}, + }}} +} + +// EXT-REG-1: module identity is (Source, ID); sources are validated segments. +func TestBuildRegistryValidatesSource(t *testing.T) { + rejects := map[string][]ModuleDescriptor{ + "empty source": {srcModule("", "a")}, + "source with slash": {srcModule("x/y", "a")}, + "source not utf8": {srcModule(SourceID([]byte{0xff, 0xfe}), "a")}, + "duplicate (source, id)": {srcModule("app", "a"), srcModule("app", "a")}, + "app id colliding first-party under twilight": {noteModule("a"), srcModule(SourceTwilight, "a")}, + "requirement without source": {noteModule("a"), {Source: "app", ID: "b", Requires: []ModuleRequirement{{Module: "a"}}}}, + } + for name, modules := range rejects { + if _, err := BuildRegistry(session.ProtocolVersion1, modules...); err == nil { + t.Errorf("%s: registry built", name) + } + } + // The same ID under two sources coexists and both prefixes resolve. + r, err := BuildRegistry(session.ProtocolVersion1, noteModule("a"), srcModule("app", "a")) + if err != nil { + t.Fatalf("two sources, one id: %v", err) + } + if key, ok := r.ModuleOf("app/a/note"); !ok || key != (ModuleKey{Source: "app", ID: "a"}) { + t.Fatalf("ModuleOf app/a/note = %+v %v", key, ok) + } + if key, ok := r.ModuleOf(tpfx("a") + "note"); !ok || key != TwilightModule("a") { + t.Fatalf("ModuleOf twilight/a/note = %+v %v", key, ok) + } + if _, ok := r.ModuleOf("ghost/a/note"); ok { + t.Fatal("unregistered source resolved") + } +} + +// Encode adds v; Decode selects the codec by v and keeps unknown versions raw. +func TestRegistryPayloadVersion(t *testing.T) { + r, err := BuildRegistry(session.ProtocolVersion1, noteModule("a")) + if err != nil { + t.Fatal(err) + } + typ := tpfx("a") + "note" + wire, v, err := r.Encode(typ, notePayload{Text: "hi"}) + if err != nil || v != 1 || wire.String() != `{"text":"hi","v":1}` { + t.Fatalf("encode = %s v%d %v", wire, v, err) + } + decoded, err := r.Decode(session.SessionEvent{Type: typ, Payload: wire}) + if err != nil || decoded.Unknown || decoded.Value.(notePayload).Text != "hi" { + t.Fatalf("decode = %+v %v", decoded, err) + } + future, err := r.Decode(session.SessionEvent{Type: typ, Payload: jsonstable.MustParse(`{"text":"hi","v":2}`)}) + if err != nil || !future.Unknown || future.Version != 2 { + t.Fatalf("future version = %+v %v", future, err) + } + if _, _, err := r.Encode("twilight/a/other", notePayload{}); err == nil { + t.Fatal("unknown type encoded") + } +} + +type fixture struct { + store *session.MemoryStore + registry *Registry + bindings *artifact.MemoryBindingStore + ledger *artifact.MemoryLedger +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + f := &fixture{} + f.store = session.NewMemoryStore() + r, err := BuildRegistry(session.ProtocolVersion1, noteModule("a")) + if err != nil { + t.Fatal(err) + } + f.registry = r + f.bindings = artifact.NewMemoryBindingStore() + f.ledger = artifact.NewMemoryLedger(artifact.SetBuilder{Resolver: f.bindings}) + if _, err := f.store.Create(context.Background(), session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: "s"}); err != nil { + t.Fatal(err) + } + return f +} + +func (f *fixture) admission() Admission { return Admission{Bindings: f.bindings, Ledger: f.ledger} } + +func (f *fixture) open(t *testing.T, takeover bool) Writer { + t.Helper() + w, err := OpenWriter(context.Background(), f.store, f.registry, f.admission(), "s", session.OpenOptions{Takeover: takeover}) + if err != nil { + t.Fatalf("open writer: %v", err) + } + return w +} + +func noteGroup(id string, texts ...string) CommitFn { + return func(View) (*SemanticGroup, error) { + g := &SemanticGroup{CommitID: session.CommitID(id)} + for _, tx := range texts { + g.Events = append(g.Events, TypedEvent{Type: tpfx("a") + "note", Value: notePayload{Text: tx}}) + } + return g, nil + } +} + +func notes(t *testing.T, w Writer) []string { + t.Helper() + state, _, err := w.Projections().Load(context.Background(), "s", ProjectionID(string(tpfx("a"))+"notes"), 1) + if err != nil { + t.Fatal(err) + } + return state.(noteState).Notes +} + +// EXT-WRT-1/2: serial commits, in-memory idempotency, rebuild on reopen, +// projections visible through View and Projections(). +func TestWriterCommitReplayAndRebuild(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + w := f.open(t, false) + res, err := w.Commit(ctx, noteGroup("c1", "one", "two")) + if err != nil || res.Outcome != CommitApplied || len(res.Events) != 2 || res.Events[0].Seq != 0 { + t.Fatalf("commit = %+v %v", res, err) + } + if got := notes(t, w); len(got) != 2 || got[1] != "two" { + t.Fatalf("projection after commit = %v", got) + } + replay, _ := w.Commit(ctx, noteGroup("c1", "one", "two")) + if replay.Outcome != CommitAlreadyApplied || len(replay.Events) != 2 || replay.Events[1].Digest != res.Events[1].Digest { + t.Fatalf("replay = %+v", replay) + } + conflict, _ := w.Commit(ctx, noteGroup("c1", "changed")) + if conflict.Outcome != CommitConflict { + t.Fatalf("conflict = %+v", conflict) + } + noop, _ := w.Commit(ctx, func(View) (*SemanticGroup, error) { return nil, nil }) + if noop.Outcome != CommitNoop { + t.Fatalf("noop = %+v", noop) + } + invalid, _ := w.Commit(ctx, func(View) (*SemanticGroup, error) { + return &SemanticGroup{CommitID: "c2", Events: []TypedEvent{{Type: "twilight/a/unknown", Value: notePayload{}}}}, nil + }) + if invalid.Outcome != CommitInvalid { + t.Fatalf("invalid = %+v", invalid) + } + rejected, _ := w.Commit(ctx, noteGroup("c3", "fine", "reject")) + if rejected.Outcome != CommitInvalid { + t.Fatalf("projection rejection must block the append: %+v", rejected) + } + if page, _ := f.store.Read(ctx, session.ReadRequest{SessionID: "s"}); len(page.Events) != 2 { + t.Fatalf("rejected groups wrote rows: %d", len(page.Events)) + } + // The View sees head, index and projection; fn may use them. + _, err = w.Commit(ctx, func(v View) (*SemanticGroup, error) { + if v.Head().Next != 2 || v.Epoch() != 1 { + t.Fatalf("view head/epoch = %+v %d", v.Head(), v.Epoch()) + } + if rows, ok := v.LookupCommit("c1"); !ok || len(rows) != 2 { + t.Fatal("view lookup failed") + } + if s, err := v.Projection(ProjectionID(string(tpfx("a"))+"notes"), 1); err != nil || len(s.(noteState).Notes) != 2 { + t.Fatalf("view projection = %+v %v", s, err) + } + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + if _, err := OpenWriter(ctx, f.store, f.registry, f.admission(), "s", session.OpenOptions{}); !session.IsCode(err, session.ErrOwned) { + t.Fatalf("second writer = %v, want owned", err) + } + if err := w.Close(ctx); err != nil { + t.Fatal(err) + } + if _, err := w.Commit(ctx, noteGroup("c4", "x")); err == nil { + t.Fatal("closed writer accepted a commit") + } + w2 := f.open(t, false) + if w2.Epoch() != 2 { + t.Fatalf("epoch = %d", w2.Epoch()) + } + if got := notes(t, w2); len(got) != 2 { + t.Fatalf("rebuilt projection = %v", got) + } + if again, _ := w2.Commit(ctx, noteGroup("c1", "one", "two")); again.Outcome != CommitAlreadyApplied { + t.Fatalf("index not rebuilt: %+v", again) + } + reader := NewProjectionReader(f.store, f.registry, nil) + state, through, err := reader.Load(ctx, "s", ProjectionID(string(tpfx("a"))+"notes"), 1) + if err != nil || len(state.(noteState).Notes) != 2 || through.Next != 2 { + t.Fatalf("store reader = %+v %+v %v", state, through, err) + } +} + +// EXT-WRT-4: a superseded writer fails closed. +func TestWriterOwnershipLost(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + w1 := f.open(t, false) + if _, err := w1.Commit(ctx, noteGroup("c1", "one")); err != nil { + t.Fatal(err) + } + w2 := f.open(t, true) + if _, err := w1.Commit(ctx, noteGroup("c2", "late")); !errors.Is(err, &Error{Code: ErrOwnershipLost}) { + t.Fatalf("stale writer commit = %v, want ownership_lost", err) + } + if _, err := w1.Commit(ctx, noteGroup("c3", "again")); !errors.Is(err, &Error{Code: ErrOwnershipLost}) { + t.Fatal("writer did not stay failed") + } + if got := notes(t, w2); len(got) != 1 { + t.Fatalf("fenced write leaked: %v", got) + } + ws := NewWriters(f.store, f.registry, f.admission(), session.OpenOptions{}) + if _, err := ws.Writer(ctx, "s"); !session.IsCode(err, session.ErrOwned) { + t.Fatalf("writers while owned = %v", err) + } + _ = w2.Close(ctx) + a, err := ws.Writer(ctx, "s") + if err != nil { + t.Fatal(err) + } + if b, _ := ws.Writer(ctx, "s"); a != b { + t.Fatal("Writers handed out two writers for one session") + } +} + +// EXT-PRJ-2: unknown events in scope fail the fold unless Ignorable; unknown +// events of other modules are skipped. +func TestProjectionUnknownEvents(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + w := f.open(t, false) + if _, err := w.Commit(ctx, noteGroup("c1", "one")); err != nil { + t.Fatal(err) + } + hint, _ := w.Commit(ctx, func(View) (*SemanticGroup, error) { + return &SemanticGroup{CommitID: "c2", Events: []TypedEvent{{Type: tpfx("a") + "hint", Value: notePayload{Text: "h"}}}}, nil + }) + if hint.Outcome != CommitApplied || !hint.Events[0].Ignorable { + t.Fatalf("ignorable definition not applied to the row: %+v", hint) + } + _ = w.Close(ctx) + kw, _ := f.store.Open(ctx, "s", session.OpenOptions{}) + raw := func(id, typ string, ignorable bool) { + if _, err := kw.Append(ctx, session.Group{CommitID: session.CommitID(id), Events: []session.UncommittedEvent{{Type: session.EventType(typ), Payload: jsonstable.MustParse(`{"v":1}`), Ignorable: ignorable}}}); err != nil { + t.Fatal(err) + } + } + raw("other", "twilight/zzz/thing", false) // out of scope: skipped + raw("future", "twilight/a/future", true) // in scope, ignorable: skipped + _ = kw.Close(ctx) + w = f.open(t, false) + if got := notes(t, w); len(got) != 1 { + t.Fatalf("notes = %v", got) + } + _ = w.Close(ctx) + kw, _ = f.store.Open(ctx, "s", session.OpenOptions{}) + raw("strict", "twilight/a/strict", false) // in scope, not ignorable: fold fails + _ = kw.Close(ctx) + if _, err := OpenWriter(ctx, f.store, f.registry, f.admission(), "s", session.OpenOptions{}); !errors.Is(err, &Error{Code: ErrUnknownEvent}) { + t.Fatalf("open with unknown strict event = %v", err) + } +} + +// EXT-WRT-3 and ART-RET-3: claims are Active before the rows exist; an +// orphan claim is released on the next OpenWriter; a live claim survives. +func TestWriterClaimsAndReconcile(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + b, _ := artifact.NewBinding("b1", artifact.Ref{Scheme: "spill", Authority: "local", Key: "k", Durability: artifact.EventBound}) + if _, err := f.bindings.CreateBinding(ctx, b); err != nil { + t.Fatal(err) + } + w := f.open(t, false) + res, err := w.Commit(ctx, func(View) (*SemanticGroup, error) { + return &SemanticGroup{CommitID: "c1", Events: []TypedEvent{{Type: tpfx("a") + "note", Value: notePayload{Text: "file", Refs: []string{"b1"}}}}}, nil + }) + if err != nil || res.Outcome != CommitApplied || res.Claim == nil || res.Claim.State != artifact.ClaimActive { + t.Fatalf("commit with binding = %+v %v", res, err) + } + missing, _ := w.Commit(ctx, func(View) (*SemanticGroup, error) { + return &SemanticGroup{CommitID: "c2", Events: []TypedEvent{{Type: tpfx("a") + "note", Value: notePayload{Text: "x", Refs: []string{"nope"}}}}}, nil + }) + if missing.Outcome != CommitInvalid { + t.Fatalf("unknown binding = %+v", missing) + } + // Simulate a crash between claim and append: an Active claim whose owner + // commit never made it into the stream. + set, _ := artifact.SetBuilder{Resolver: f.bindings}.Build(ctx, []artifact.BindingID{"b1"}) + orphanID := DeriveClaimID(session.ProtocolVersion1, "s", "never", set.RefSetDigest) + if _, err := f.ledger.Activate(ctx, orphanID, CommitOwner("s", "never"), set); err != nil { + t.Fatal(err) + } + _ = w.Close(ctx) + w = f.open(t, false) + defer w.Close(ctx) + if c, ok, _ := f.ledger.LookupClaim(ctx, orphanID); !ok || c.State != artifact.ClaimReleased { + t.Fatalf("orphan claim = %+v", c) + } + if c, ok, _ := f.ledger.LookupClaim(ctx, res.Claim.ID); !ok || c.State != artifact.ClaimActive { + t.Fatalf("live claim = %+v", c) + } +} + +// EXT-PRJ-3/4: cache entry plus tail equals the full fold; a stale or missing +// entry falls back to a full fold; Writer and Store readers agree. +func TestProjectionCache(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + w := f.open(t, false) + defer w.Close(ctx) + id := ProjectionID(string(tpfx("a")) + "notes") + _, _ = w.Commit(ctx, noteGroup("c1", "one")) + cache := NewMemoryProjectionCache() + state, through, _ := w.Projections().Load(ctx, "s", id, 1) + if err := SaveProjection(ctx, cache, f.registry, "s", id, 1, state, through); err != nil { + t.Fatal(err) + } + _, _ = w.Commit(ctx, noteGroup("c2", "two")) + reader := NewProjectionReader(f.store, f.registry, cache) + got, head, err := reader.Load(ctx, "s", id, 1) + if err != nil || len(got.(noteState).Notes) != 2 || head.Next != 2 { + t.Fatalf("cache+tail = %+v %+v %v", got, head, err) + } + // A cache entry claiming a head the stream does not have is ignored. + _ = cache.Save(ctx, "s", id, 1, jsonstable.MustParse(`{"notes":["bogus"]}`), session.Head{Next: 1, Digest: "sha256:wrong"}) + got, _, err = reader.Load(ctx, "s", id, 1) + if err != nil || got.(noteState).Notes[0] != "one" { + t.Fatalf("stale cache used: %+v %v", got, err) + } + cache.Delete("s", id, 1) + got, _, _ = reader.Load(ctx, "s", id, 1) + mem, _, _ := w.Projections().Load(ctx, "s", id, 1) + if len(got.(noteState).Notes) != len(mem.(noteState).Notes) { + t.Fatal("store reader and writer reader disagree") + } +} diff --git a/agent/session/extension/projection.go b/agent/session/extension/projection.go new file mode 100644 index 0000000..0ca8898 --- /dev/null +++ b/agent/session/extension/projection.go @@ -0,0 +1,259 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/session" +) + +// ProjectionDefinition is a pure fold over decoded events (EXT-PRJ-1). +type ProjectionDefinition struct { + ID ProjectionID + Version ProjectionVersion + Consumes []session.EventType + Initial func() (any, error) + Apply func(any, DecodedEvent) (any, error) + StateCodec PayloadCodec +} + +// projectionScope is a definition bound to its module scope: the type prefixes +// a reader filters on and the modules whose unknown events must not be skipped. +type projectionScope struct { + def ProjectionDefinition + consumes map[session.EventType]struct{} + modules map[ModuleKey]struct{} + types []session.EventType +} + +func (r *Registry) scopeFor(id ProjectionID, v ProjectionVersion) (*projectionScope, error) { + def, module, ok := r.LookupProjection(id, v) + if !ok { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("unknown projection %q v%d", id, v)} + } + s := &projectionScope{def: def, consumes: make(map[session.EventType]struct{}, len(def.Consumes)), modules: r.scopeOf(module)} + for _, t := range def.Consumes { + s.consumes[t] = struct{}{} + } + for m := range s.modules { + s.types = append(s.types, ModulePrefix(m.Source, m.ID)) + } + return s, nil +} + +// fold applies rows to state group by group (EXT-PRJ-1/2). rows must be +// whole groups in Seq order. +func (r *Registry) fold(s *projectionScope, state any, rows []session.SessionEvent) (any, error) { + for i := 0; i < len(rows); { + end := i + for end < len(rows) && !rows[end].Last { + end++ + } + if end >= len(rows) { + return nil, &Error{Code: ErrInvalid, Detail: "fold received an incomplete group"} + } + next := state + for j := i; j <= end; j++ { + var err error + next, err = r.applyRow(s, next, &rows[j]) + if err != nil { + return nil, err + } + } + state = next + i = end + 1 + } + return state, nil +} + +func (r *Registry) applyRow(s *projectionScope, state any, row *session.SessionEvent) (any, error) { + if _, want := s.consumes[row.Type]; !want { + if _, registered := r.events[row.Type]; registered { + return state, nil // known type of some module, not consumed here + } + module, known := r.ModuleOf(row.Type) + if _, inScope := s.modules[module]; known && inScope && !row.Ignorable { + return nil, &Error{Code: ErrUnknownEvent, Type: row.Type, Detail: fmt.Sprintf("projection %q: unregistered non-ignorable event of module %s/%s at seq %d", s.def.ID, module.Source, module.ID, row.Seq)} + } + return state, nil + } + decoded, err := r.Decode(*row) + if err != nil { + return nil, err + } + if decoded.Unknown { + if row.Ignorable { + return state, nil + } + return nil, &Error{Code: ErrUnknownEvent, Type: row.Type, Detail: fmt.Sprintf("projection %q cannot decode v%d at seq %d", s.def.ID, decoded.Version, row.Seq)} + } + next, err := s.def.Apply(state, decoded) + if err != nil { + return nil, fmt.Errorf("projection %s: seq %d: %w", s.def.ID, row.Seq, err) + } + return next, nil +} + +// ProjectionReader loads a projection state together with the stream head it +// covers. +type ProjectionReader interface { + Load(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (state any, through session.Head, err error) +} + +// ProjectionCache is the optional derived cache of EXT-PRJ-3. Entries may be +// lost or stale at any time; readers verify Through against the stream. +type ProjectionCache interface { + Load(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (state jsonstable.Value, through session.Head, ok bool, err error) + Save(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion, state jsonstable.Value, through session.Head) error +} + +// MemoryProjectionCache is the in-process ProjectionCache. +type MemoryProjectionCache struct { + mu sync.Mutex + entries map[cacheKey]cacheEntry +} + +type cacheKey struct { + sid session.SessionID + id ProjectionID + v ProjectionVersion +} +type cacheEntry struct { + state jsonstable.Value + through session.Head +} + +func NewMemoryProjectionCache() *MemoryProjectionCache { + return &MemoryProjectionCache{entries: make(map[cacheKey]cacheEntry)} +} + +func (c *MemoryProjectionCache) Load(_ context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (jsonstable.Value, session.Head, bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[cacheKey{sid, id, v}] + return e.state, e.through, ok, nil +} + +func (c *MemoryProjectionCache) Save(_ context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion, state jsonstable.Value, through session.Head) error { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[cacheKey{sid, id, v}] = cacheEntry{state, through} + return nil +} + +// Delete drops one entry; tests use it to prove the cache is discardable. +func (c *MemoryProjectionCache) Delete(sid session.SessionID, id ProjectionID, v ProjectionVersion) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.entries, cacheKey{sid, id, v}) +} + +// SaveProjection encodes state with the projection's StateCodec and stores it +// in cache covering through. +func SaveProjection(ctx context.Context, cache ProjectionCache, registry *Registry, sid session.SessionID, id ProjectionID, v ProjectionVersion, state any, through session.Head) error { + if cache == nil { + return nil + } + def, _, ok := registry.LookupProjection(id, v) + if !ok { + return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("unknown projection %q v%d", id, v)} + } + encoded, err := def.StateCodec.Encode(state) + if err != nil { + return err + } + return cache.Save(ctx, sid, id, v, encoded, through) +} + +type storeReader struct { + store session.Store + registry *Registry + cache ProjectionCache +} + +// NewProjectionReader reads projections from the Store: cache entry (when it +// is a prefix of the stream) plus the filtered tail, or a full fold. It is +// the observer's path; the owner process reads through Writer.Projections(). +func NewProjectionReader(store session.Store, registry *Registry, cache ProjectionCache) ProjectionReader { + return &storeReader{store: store, registry: registry, cache: cache} +} + +func (r *storeReader) Load(ctx context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (any, session.Head, error) { + scope, err := r.registry.scopeFor(id, v) + if err != nil { + return nil, session.Head{}, err + } + state, from, err := r.startState(ctx, sid, scope) + if err != nil { + return nil, session.Head{}, err + } + page, err := r.store.Read(ctx, session.ReadRequest{SessionID: sid, From: from.Next, Types: scope.types}) + if err != nil { + return nil, session.Head{}, err + } + state, err = r.registry.fold(scope, state, page.Events) + if err != nil { + return nil, session.Head{}, err + } + return state, page.Head, nil +} + +// startState returns the cached state when its Through is a prefix of the +// stream; otherwise the projection's initial state and the empty head. +func (r *storeReader) startState(ctx context.Context, sid session.SessionID, scope *projectionScope) (any, session.Head, error) { + if r.cache != nil { + encoded, through, ok, err := r.cache.Load(ctx, sid, scope.def.ID, scope.def.Version) + if err != nil { + return nil, session.Head{}, err + } + if ok && through.Next > 0 && r.isPrefix(ctx, sid, through) { + if state, err := scope.def.StateCodec.Decode(encoded); err == nil { + return state, through, nil + } + } + } + state, err := scope.def.Initial() + return state, session.Head{}, err +} + +// isPrefix checks that the row before through.Next carries through.Digest. +func (r *storeReader) isPrefix(ctx context.Context, sid session.SessionID, through session.Head) bool { + page, err := r.store.Read(ctx, session.ReadRequest{SessionID: sid, From: through.Next - 1, Limit: 1}) + if err != nil || len(page.Events) == 0 { + return false + } + return page.Events[0].Seq == through.Next-1 && page.Events[0].Digest == through.Digest +} + +// JSONStateCodec is a StateCodec for projection states that marshal to JSON. +type JSONStateCodec[T any] struct{} + +func (JSONStateCodec[T]) Validate(value any) error { + if _, ok := value.(T); !ok { + var zero T + return fmt.Errorf("state is %T, want %T", value, zero) + } + return nil +} +func (c JSONStateCodec[T]) Encode(value any) (jsonstable.Value, error) { + if err := c.Validate(value); err != nil { + return jsonstable.Value{}, err + } + return jsonstable.FromValue(value) +} +func (JSONStateCodec[T]) Decode(wire jsonstable.Value) (any, error) { + var v T + if wire.IsZero() { + return nil, errors.New("empty projection state") + } + if err := StrictDecode(wire, &v); err != nil { + return nil, err + } + return v, nil +} + +var _ = es.Digest("") diff --git a/agent/session/extension/registry.go b/agent/session/extension/registry.go new file mode 100644 index 0000000..28c0ff0 --- /dev/null +++ b/agent/session/extension/registry.go @@ -0,0 +1,478 @@ +// Package extension is the Session Module Framework +// (docs/design/agent-session-extension.md): typed event codecs with +// payload versions, Binding admission, the in-process Writer that serializes +// every write and holds the idempotency index, and pure projections with an +// optional cache. +package extension + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "unicode/utf8" + + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/session" +) + +type ( + SourceID string + ModuleID string + ProjectionID string + ProjectionVersion uint16 + PayloadVersion uint16 +) + +// SourceTwilight is the source reserved for this repository's first-party +// modules; application modules register under their own SourceID (EXT-REG-1). +const SourceTwilight SourceID = "twilight" + +// ModuleKey is the registry identity of one module: (Source, ID). +type ModuleKey struct { + Source SourceID + ID ModuleID +} + +// TwilightModule is the ModuleKey of a first-party module. +func TwilightModule(id ModuleID) ModuleKey { return ModuleKey{Source: SourceTwilight, ID: id} } + +// PayloadCodec encodes and decodes one payload version. Encode never writes +// the `v` field: the Registry adds it (EXT-COD-2). +type PayloadCodec interface { + Encode(value any) (jsonstable.Value, error) + Decode(wire jsonstable.Value) (any, error) + Validate(value any) error +} + +type EventDefinition struct { + Type session.EventType + Current PayloadVersion + Codecs map[PayloadVersion]PayloadCodec + Bindings []BindingReferenceDefinition + // Ignorable marks purely informational events: rows are written with + // session.SessionEvent.Ignorable so readers that do not know the type may + // skip them (EXT-PRJ-2). + Ignorable bool +} + +// ModuleRequirement declares that a module consumes another module's events +// and which payload versions it can handle (EXT-REG-4). Source is required: +// module identity is the (Source, ID) pair. +type ModuleRequirement struct { + Source SourceID + Module ModuleID + Events map[session.EventType][]PayloadVersion +} + +// Key is the identity the requirement points at. +func (r ModuleRequirement) Key() ModuleKey { return ModuleKey{Source: r.Source, ID: r.Module} } + +type ModuleDescriptor struct { + Source SourceID + ID ModuleID + Requires []ModuleRequirement + Events []EventDefinition + Projections []ProjectionDefinition +} + +// Key is the module's registry identity. +func (m ModuleDescriptor) Key() ModuleKey { return ModuleKey{Source: m.Source, ID: m.ID} } + +type DecodedEvent struct { + Event session.SessionEvent + Module ModuleKey + Version PayloadVersion + Value any + Unknown bool +} + +// Registry is the immutable index built once at startup (EXT-REG-1). +type Registry struct { + ProtocolVersion uint16 + + modules map[ModuleKey]ModuleDescriptor + events map[session.EventType]eventEntry + projections map[projectionKey]projectionEntry +} + +type eventEntry struct { + module ModuleKey + def EventDefinition +} + +type projectionKey struct { + id ProjectionID + version ProjectionVersion +} + +type projectionEntry struct { + module ModuleKey + def ProjectionDefinition +} + +// validSegment checks one identity segment of an EventType prefix. +func validSegment(kind, v string) error { + if v == "" { + return fmt.Errorf("empty %s", kind) + } + if strings.Contains(v, "/") { + return fmt.Errorf("%s %q contains %q", kind, v, "/") + } + if !utf8.ValidString(v) { + return fmt.Errorf("%s is not valid UTF-8", kind) + } + return nil +} + +// BuildRegistry validates the module set and freezes the indexes. +func BuildRegistry(protocolVersion uint16, modules ...ModuleDescriptor) (*Registry, error) { + if protocolVersion == 0 { + return nil, errors.New("extension: registry: zero protocol version") + } + r := &Registry{ProtocolVersion: protocolVersion, + modules: make(map[ModuleKey]ModuleDescriptor), events: make(map[session.EventType]eventEntry), projections: make(map[projectionKey]projectionEntry)} + for _, m := range modules { + if err := validSegment("source", string(m.Source)); err != nil { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("module %q: %v", m.ID, err)} + } + if err := validSegment("module id", string(m.ID)); err != nil { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("source %q: %v", m.Source, err)} + } + key := m.Key() + if _, dup := r.modules[key]; dup { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("duplicate module %s/%s", key.Source, key.ID)} + } + r.modules[key] = m + prefix := ModulePrefix(m.Source, m.ID) + for _, def := range m.Events { + if !strings.HasPrefix(string(def.Type), string(prefix)) || len(def.Type) == len(prefix) { + return nil, &Error{Code: ErrInvalid, Type: def.Type, Detail: fmt.Sprintf("event type is not under module %s/%s", key.Source, key.ID)} + } + if _, dup := r.events[def.Type]; dup { + return nil, &Error{Code: ErrInvalid, Type: def.Type, Detail: "duplicate event type"} + } + if def.Current == 0 || def.Codecs[def.Current] == nil { + return nil, &Error{Code: ErrInvalid, Type: def.Type, Detail: "no codec for the current payload version"} + } + for _, b := range def.Bindings { + if err := b.validate(); err != nil { + return nil, &Error{Code: ErrInvalid, Type: def.Type, Detail: err.Error()} + } + } + r.events[def.Type] = eventEntry{module: key, def: def} + } + for _, p := range m.Projections { + if p.ID == "" || p.Version == 0 || p.Initial == nil || p.Apply == nil || p.StateCodec == nil { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("projection %q is incomplete", p.ID)} + } + k := projectionKey{p.ID, p.Version} + if _, dup := r.projections[k]; dup { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("duplicate projection %q v%d", p.ID, p.Version)} + } + r.projections[k] = projectionEntry{module: key, def: p} + } + } + if err := r.checkRequirements(); err != nil { + return nil, err + } + return r, nil +} + +// checkRequirements enforces EXT-REG-4: registered dependencies, no cycles, +// projection consumption within scope, and handled payload versions. +func (r *Registry) checkRequirements() error { + state := make(map[ModuleKey]int) // 0 unvisited, 1 visiting, 2 done + var visit func(ModuleKey) error + visit = func(key ModuleKey) error { + switch state[key] { + case 1: + return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("module requirement cycle through %s/%s", key.Source, key.ID)} + case 2: + return nil + } + state[key] = 1 + for _, req := range r.modules[key].Requires { + if req.Source == "" { + return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("module %s/%s: requirement on %q has no source", key.Source, key.ID, req.Module)} + } + depKey := req.Key() + dep, ok := r.modules[depKey] + if !ok { + return &Error{Code: ErrInvalid, Detail: fmt.Sprintf("module %s/%s requires unregistered module %s/%s", key.Source, key.ID, depKey.Source, depKey.ID)} + } + for typ, versions := range req.Events { + entry, ok := r.events[typ] + if !ok || entry.module != dep.Key() { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("module %s/%s requires event not owned by %s/%s", key.Source, key.ID, depKey.Source, depKey.ID)} + } + if !containsVersion(versions, entry.def.Current) { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("module %s/%s handles versions %v but %s/%s currently writes v%d", key.Source, key.ID, versions, depKey.Source, depKey.ID, entry.def.Current)} + } + } + if err := visit(depKey); err != nil { + return err + } + } + state[key] = 2 + return nil + } + for key := range r.modules { + if err := visit(key); err != nil { + return err + } + } + for k, p := range r.projections { + scope := r.scopeOf(p.module) + for _, typ := range p.def.Consumes { + entry, ok := r.events[typ] + if !ok { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("projection %q consumes unregistered event", k.id)} + } + if _, inScope := scope[entry.module]; !inScope { + return &Error{Code: ErrInvalid, Type: typ, Detail: fmt.Sprintf("projection %q consumes event of module %s/%s outside its Requires", k.id, entry.module.Source, entry.module.ID)} + } + } + } + return nil +} + +// scopeOf is the module plus its Requires: the modules whose unknown events a +// projection must not silently skip (EXT-PRJ-2). +func (r *Registry) scopeOf(key ModuleKey) map[ModuleKey]struct{} { + scope := map[ModuleKey]struct{}{key: {}} + for _, req := range r.modules[key].Requires { + scope[req.Key()] = struct{}{} + } + return scope +} + +func containsVersion(vs []PayloadVersion, v PayloadVersion) bool { + for _, x := range vs { + if x == v { + return true + } + } + return false +} + +func (r *Registry) LookupEvent(typ session.EventType) (ModuleKey, EventDefinition, bool) { + e, ok := r.events[typ] + return e.module, e.def, ok +} + +// ModuleOf names the module an EventType belongs to by its +// // prefix; false when the prefix names no registered module. +func (r *Registry) ModuleOf(typ session.EventType) (ModuleKey, bool) { + parts := strings.SplitN(string(typ), "/", 3) + if len(parts) == 3 { + key := ModuleKey{Source: SourceID(parts[0]), ID: ModuleID(parts[1])} + if _, registered := r.modules[key]; registered { + return key, true + } + } + return ModuleKey{}, false +} + +func (r *Registry) LookupProjection(id ProjectionID, v ProjectionVersion) (ProjectionDefinition, ModuleKey, bool) { + e, ok := r.projections[projectionKey{id, v}] + return e.def, e.module, ok +} + +// Projections lists every registered projection with its owning module. +func (r *Registry) Projections() []ProjectionDefinition { + out := make([]ProjectionDefinition, 0, len(r.projections)) + for _, e := range r.projections { + out = append(out, e.def) + } + return out +} + +// ModulePrefix is the EventType prefix of one module: //. +func ModulePrefix(source SourceID, id ModuleID) session.EventType { + return session.EventType(fmt.Sprintf("%s/%s/", source, id)) +} + +// Encode validates value, encodes it with the current codec and adds `v`. +func (r *Registry) Encode(typ session.EventType, value any) (jsonstable.Value, PayloadVersion, error) { + _, def, ok := r.LookupEvent(typ) + if !ok { + return jsonstable.Value{}, 0, &Error{Code: ErrUnknownEvent, Type: typ} + } + codec := def.Codecs[def.Current] + if err := codec.Validate(value); err != nil { + return jsonstable.Value{}, 0, &Error{Code: ErrCodec, Type: typ, Detail: err.Error()} + } + body, err := codec.Encode(value) + if err != nil { + return jsonstable.Value{}, 0, &Error{Code: ErrCodec, Type: typ, Detail: err.Error()} + } + wire, err := addVersion(body, def.Current) + if err != nil { + return jsonstable.Value{}, 0, &Error{Code: ErrCodec, Type: typ, Detail: err.Error()} + } + // The canonical Encode/Decode/Encode round trip is a module test + // obligation (EXT-COD-1), not re-verified per Encode. + return wire, def.Current, nil +} + +// Decode selects the codec by (EventType, v). Unknown types or versions are +// returned as Unknown with the raw payload retained (EXT-REG-3). +func (r *Registry) Decode(e session.SessionEvent) (DecodedEvent, error) { + out := DecodedEvent{Event: e} + module, def, ok := r.LookupEvent(e.Type) + if !ok { + out.Module, _ = r.ModuleOf(e.Type) + out.Unknown = true + return out, nil + } + out.Module = module + body, v, err := splitVersion(e.Payload) + if err != nil { + return out, &Error{Code: ErrCodec, Type: e.Type, Detail: err.Error()} + } + out.Version = v + codec := def.Codecs[v] + if codec == nil { + out.Unknown = true + return out, nil + } + value, err := codec.Decode(body) + if err != nil { + return out, &Error{Code: ErrCodec, Type: e.Type, Detail: err.Error()} + } + out.Value = value + return out, nil +} + +// addVersion inserts the integer `v` field into the first level of body. +func addVersion(body jsonstable.Value, v PayloadVersion) (jsonstable.Value, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(body.Bytes(), &m); err != nil { + return jsonstable.Value{}, fmt.Errorf("payload is not an object: %w", err) + } + if m == nil { + m = map[string]json.RawMessage{} + } + if _, has := m["v"]; has { + return jsonstable.Value{}, errors.New("payload must not define its own \"v\" field") + } + m["v"] = json.RawMessage(fmt.Sprintf("%d", v)) + return jsonstable.FromValue(m) +} + +// splitVersion removes `v` and returns the codec-facing body. +func splitVersion(payload jsonstable.Value) (jsonstable.Value, PayloadVersion, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(payload.Bytes(), &m); err != nil { + return jsonstable.Value{}, 0, fmt.Errorf("payload is not an object: %w", err) + } + raw, ok := m["v"] + if !ok { + return jsonstable.Value{}, 0, errors.New("payload has no \"v\" field") + } + var v uint16 + if err := json.Unmarshal(raw, &v); err != nil || v == 0 { + return jsonstable.Value{}, 0, errors.New("payload \"v\" is not a positive integer") + } + delete(m, "v") + body, err := jsonstable.FromValue(m) + if err != nil { + return jsonstable.Value{}, 0, err + } + return body, PayloadVersion(v), nil +} + +// JSONCodec is a PayloadCodec for a plain Go struct type T with json tags. +// Decode is strict: unknown fields, duplicate keys and trailing data are +// rejected by the canonical parse and DisallowUnknownFields. +type JSONCodec[T any] struct { + // Check validates a decoded/encoded value; nil accepts every T. + Check func(*T) error +} + +func (c JSONCodec[T]) Validate(value any) error { + v, ok := value.(T) + if !ok { + p, isPtr := value.(*T) + if !isPtr || p == nil { + return fmt.Errorf("value is %T, want %T", value, v) + } + v = *p + } + if c.Check != nil { + return c.Check(&v) + } + return nil +} + +func (c JSONCodec[T]) Encode(value any) (jsonstable.Value, error) { + if err := c.Validate(value); err != nil { + return jsonstable.Value{}, err + } + if p, ok := value.(*T); ok { + value = *p + } + return jsonstable.FromValue(value) +} + +func (c JSONCodec[T]) Decode(wire jsonstable.Value) (any, error) { + var v T + if err := StrictDecode(wire, &v); err != nil { + return nil, err + } + if c.Check != nil { + if err := c.Check(&v); err != nil { + return nil, err + } + } + return v, nil +} + +// StrictDecode decodes canonical JSON into dst rejecting unknown fields. +func StrictDecode(wire jsonstable.Value, dst any) error { + dec := json.NewDecoder(strings.NewReader(wire.String())) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + if dec.More() { + return errors.New("trailing data after JSON value") + } + return nil +} + +// --- errors ------------------------------------------------------------------- + +type ErrorCode string + +const ( + ErrInvalid ErrorCode = "invalid" + ErrUnknownEvent ErrorCode = "unknown_event" + ErrCodec ErrorCode = "codec" + ErrBinding ErrorCode = "binding" + ErrConflict ErrorCode = "conflict" + ErrOwnershipLost ErrorCode = "ownership_lost" +) + +type Error struct { + Code ErrorCode + Type session.EventType + Detail string +} + +func (e *Error) Error() string { + s := "extension: " + string(e.Code) + if e.Type != "" { + s += " " + string(e.Type) + } + if e.Detail != "" { + s += ": " + e.Detail + } + return s +} + +func (e *Error) Is(target error) bool { + t, ok := target.(*Error) + return ok && t.Code == e.Code +} diff --git a/agent/session/extension/writer.go b/agent/session/extension/writer.go new file mode 100644 index 0000000..06ee965 --- /dev/null +++ b/agent/session/extension/writer.go @@ -0,0 +1,508 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/felinics/twilight/agent/artifact" + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/session" +) + +// TypedEvent is a module value plus row metadata. Ignorable comes from the +// EventDefinition, not from the caller. +type TypedEvent struct { + Type session.EventType + RecordedAtUnixMilli int64 + SourceSeqs []session.Seq + Value any +} + +type SemanticGroup struct { + CommitID session.CommitID + Events []TypedEvent +} + +// View is what a CommitFn may read: head, idempotency index and projections +// folded to the current head (EXT-WRT-1). +type View interface { + Head() session.Head + Epoch() session.Epoch + LookupCommit(session.CommitID) ([]session.SessionEvent, bool) + Projection(ProjectionID, ProjectionVersion) (any, error) +} + +// CommitFn decides the group to write; nil means write nothing. +type CommitFn func(View) (*SemanticGroup, error) + +type CommitOutcome string + +const ( + CommitApplied CommitOutcome = "applied" + CommitAlreadyApplied CommitOutcome = "already_applied" // same CommitID, same fingerprint + CommitConflict CommitOutcome = "conflict" // same CommitID, different fingerprint + CommitInvalid CommitOutcome = "invalid" + CommitNoop CommitOutcome = "noop" +) + +type CommitResult struct { + Outcome CommitOutcome + Events []session.SessionEvent + Claim *artifact.RetentionClaim + Detail string +} + +// Writer is the single in-process write entry of one Session (EXT-SCP-1). +type Writer interface { + SessionID() session.SessionID + Epoch() session.Epoch + Commit(context.Context, CommitFn) (CommitResult, error) + Projections() ProjectionReader + // OwnerExists reports whether a CommitID is in this stream; artifact's + // reconciliation uses it through artifact.OwnerVerifier. + OwnerExists(context.Context, artifact.ClaimOwner) (bool, error) + Close(context.Context) error +} + +// Writers is the host-maintained SessionID to Writer map (EXT-WRT-6). +type Writers interface { + Writer(context.Context, session.SessionID) (Writer, error) +} + +// Admission supplies Binding admission and the claim ledger. Both may be nil +// when no registered event declares Bindings. +type Admission struct { + Bindings artifact.BindingResolver + Ledger artifact.RetentionLedger +} + +// ClaimOwnerKind is the ClaimOwner.Kind of Session commits (EXT-WRT-5). +const ClaimOwnerKind = "twilight/session/commit" + +// DeriveClaimID is EXT-WRT-5. +func DeriveClaimID(protocolVersion uint16, sid session.SessionID, commitID session.CommitID, refSet artifact.RefSetDigest) artifact.ClaimID { + raw, _ := es.EncodeTypedPayload(session.ProtocolVersion1, "twilight/session-extension/claim", []string{"1", fmt.Sprintf("%d", protocolVersion), string(sid), string(commitID), string(refSet)}) + return artifact.ClaimID(es.DigestBytes(raw)) +} + +// CommitOwner is the ClaimOwner of a Session commit. +func CommitOwner(sid session.SessionID, id session.CommitID) artifact.ClaimOwner { + return artifact.ClaimOwner{Kind: ClaimOwnerKind, Authority: string(sid), Identity: string(id)} +} + +type indexed struct { + rows []session.SessionEvent + fingerprint es.Digest +} + +type writer struct { + mu sync.Mutex + kernel session.Writer + registry *Registry + admission Admission + sid session.SessionID + head session.Head + index map[session.CommitID]indexed + states map[projectionKey]any + scopes map[projectionKey]*projectionScope + lost error +} + +// OpenWriter takes ownership of sid and rebuilds the idempotency index and +// every registered projection from the whole log (EXT-WRT-1). When a ledger +// is configured it reconciles this Session's claims before returning +// (ART-RET-3): no Commit can be in flight yet. +func OpenWriter(ctx context.Context, store session.Store, registry *Registry, admission Admission, sid session.SessionID, opts session.OpenOptions) (Writer, error) { + if store == nil || registry == nil { + return nil, errors.New("extension: writer: nil store or registry") + } + kernel, err := store.Open(ctx, sid, opts) + if err != nil { + return nil, err + } + w := &writer{kernel: kernel, registry: registry, admission: admission, sid: sid, + index: make(map[session.CommitID]indexed), states: make(map[projectionKey]any), scopes: make(map[projectionKey]*projectionScope)} + if err := w.rebuild(ctx, store); err != nil { + _ = kernel.Close(ctx) + return nil, err + } + if admission.Ledger != nil { + if _, err := artifact.Reconcile(ctx, admission.Ledger, artifact.ClaimOwnerScope{Kind: ClaimOwnerKind, Authority: string(sid)}, w); err != nil { + _ = kernel.Close(ctx) + return nil, err + } + } + return w, nil +} + +func (w *writer) rebuild(ctx context.Context, store session.Store) error { + page, err := store.Read(ctx, session.ReadRequest{SessionID: w.sid}) + if err != nil { + return err + } + for k := range w.registry.projections { + scope, err := w.registry.scopeFor(k.id, k.version) + if err != nil { + return err + } + state, err := scope.def.Initial() + if err != nil { + return err + } + w.scopes[k] = scope + w.states[k] = state + } + rows := page.Events + for i := 0; i < len(rows); { + end := i + for end < len(rows) && !rows[end].Last { + end++ + } + if end >= len(rows) { + return &Error{Code: ErrInvalid, Detail: "log ends in an incomplete group"} + } + group := rows[i : end+1] + if err := w.foldGroup(group); err != nil { + return err + } + fp, err := fingerprintRows(w.sid, group) + if err != nil { + return err + } + w.index[group[0].CommitID] = indexed{rows: group, fingerprint: fp} + i = end + 1 + } + w.head = page.Head + return nil +} + +// foldGroup folds one complete group into every projection; no state is +// published if any projection rejects the group. +func (w *writer) foldGroup(group []session.SessionEvent) error { + next := make(map[projectionKey]any, len(w.states)) + for k, scope := range w.scopes { + state, err := w.registry.fold(scope, w.states[k], group) + if err != nil { + return err + } + next[k] = state + } + for k, s := range next { + w.states[k] = s + } + return nil +} + +func (w *writer) SessionID() session.SessionID { return w.sid } +func (w *writer) Epoch() session.Epoch { return w.kernel.Epoch() } + +func (w *writer) OwnerExists(_ context.Context, owner artifact.ClaimOwner) (bool, error) { + if owner.Kind != ClaimOwnerKind || owner.Authority != string(w.sid) { + return false, &artifact.Error{Code: artifact.ErrInvalid, Operation: "owner_exists", Detail: "owner is not a commit of this session"} + } + w.mu.Lock() + defer w.mu.Unlock() + _, ok := w.index[session.CommitID(owner.Identity)] + return ok, nil +} + +func (w *writer) Close(ctx context.Context) error { + w.mu.Lock() + defer w.mu.Unlock() + w.lost = &Error{Code: ErrInvalid, Detail: "writer closed"} + return w.kernel.Close(ctx) +} + +// --- view -------------------------------------------------------------------------- + +type view struct{ w *writer } + +func (v view) Head() session.Head { return v.w.head } +func (v view) Epoch() session.Epoch { return v.w.kernel.Epoch() } +func (v view) LookupCommit(id session.CommitID) ([]session.SessionEvent, bool) { + e, ok := v.w.index[id] + if !ok { + return nil, false + } + return append([]session.SessionEvent(nil), e.rows...), true +} +func (v view) Projection(id ProjectionID, ver ProjectionVersion) (any, error) { + state, ok := v.w.states[projectionKey{id, ver}] + if !ok { + return nil, &Error{Code: ErrInvalid, Detail: fmt.Sprintf("unknown projection %q v%d", id, ver)} + } + return state, nil +} + +type memoryReader struct{ w *writer } + +func (w *writer) Projections() ProjectionReader { return memoryReader{w} } + +func (r memoryReader) Load(_ context.Context, sid session.SessionID, id ProjectionID, v ProjectionVersion) (any, session.Head, error) { + if sid != r.w.sid { + return nil, session.Head{}, &Error{Code: ErrInvalid, Detail: "writer projections are session-local"} + } + r.w.mu.Lock() + defer r.w.mu.Unlock() + state, err := view{r.w}.Projection(id, v) + return state, r.w.head, err +} + +// --- commit -------------------------------------------------------------------------- + +func (w *writer) Commit(ctx context.Context, fn CommitFn) (CommitResult, error) { + if fn == nil { + return CommitResult{}, errors.New("extension: writer: nil fn") + } + w.mu.Lock() + defer w.mu.Unlock() + if w.lost != nil { + return CommitResult{}, w.lost + } + group, err := fn(view{w}) + if err != nil { + return CommitResult{}, err + } + if group == nil { + return CommitResult{Outcome: CommitNoop}, nil + } + if group.CommitID == "" || len(group.Events) == 0 { + return CommitResult{Outcome: CommitInvalid, Detail: "empty CommitID or event group"}, nil + } + rows, uncommitted, refs, invalid, err := w.encode(ctx, group) + if err != nil { + return CommitResult{}, err + } + if invalid != "" { + return CommitResult{Outcome: CommitInvalid, Detail: invalid}, nil + } + fp, err := fingerprintRows(w.sid, rows) + if err != nil { + return CommitResult{}, err + } + if existing, ok := w.index[group.CommitID]; ok { + if existing.fingerprint == fp { + return CommitResult{Outcome: CommitAlreadyApplied, Events: append([]session.SessionEvent(nil), existing.rows...)}, nil + } + return CommitResult{Outcome: CommitConflict}, nil + } + // Projections must accept the group before anything is persisted; the + // provisional rows carry every field Apply may read except Digest. + next := make(map[projectionKey]any, len(w.states)) + for k, scope := range w.scopes { + state, err := w.registry.fold(scope, w.states[k], rows) + if err != nil { + return CommitResult{Outcome: CommitInvalid, Detail: err.Error()}, nil + } + next[k] = state + } + var claim *artifact.RetentionClaim + if len(refs) > 0 { + claim, invalid, err = w.claim(ctx, group.CommitID, refs) + if err != nil { + return CommitResult{}, err + } + if invalid != "" { + return CommitResult{Outcome: CommitInvalid, Detail: invalid}, nil + } + } + sealed, err := w.kernel.Append(ctx, session.Group{CommitID: group.CommitID, Events: uncommitted}) + if err != nil { + if claim != nil { + _ = w.admission.Ledger.ReleaseActive(ctx, claim.ID) // best effort; an orphan is reconciled later + } + if session.IsCode(err, session.ErrOwnershipLost) { + w.lost = &Error{Code: ErrOwnershipLost, Detail: err.Error()} + return CommitResult{}, w.lost + } + if session.IsCode(err, session.ErrConflict) { + return CommitResult{Outcome: CommitConflict, Detail: err.Error()}, nil + } + return CommitResult{}, err + } + for k, s := range next { + w.states[k] = s + } + w.index[group.CommitID] = indexed{rows: sealed, fingerprint: fp} + w.head = w.kernel.Head() + return CommitResult{Outcome: CommitApplied, Events: append([]session.SessionEvent(nil), sealed...), Claim: claim}, nil +} + +// encode validates and encodes the group, extracts and admits bindings, and +// returns provisional rows (Seq assigned, Digest empty) plus the kernel input. +func (w *writer) encode(ctx context.Context, group *SemanticGroup) ([]session.SessionEvent, []session.UncommittedEvent, []artifact.BindingID, string, error) { + rows := make([]session.SessionEvent, len(group.Events)) + uncommitted := make([]session.UncommittedEvent, len(group.Events)) + var refs []artifact.BindingID + for i, te := range group.Events { + _, def, ok := w.registry.LookupEvent(te.Type) + if !ok { + return nil, nil, nil, fmt.Sprintf("event %d: unknown type %s", i, te.Type), nil + } + payload, _, err := w.registry.Encode(te.Type, te.Value) + if err != nil { + return nil, nil, nil, fmt.Sprintf("event %d: %v", i, err), nil + } + for _, decl := range def.Bindings { + ids, err := decl.Extractor.BindingIDs(te.Value) + if err != nil { + return nil, nil, nil, fmt.Sprintf("event %d: binding extraction: %v", i, err), nil + } + if uint32(len(ids)) < decl.Cardinality.Min || (decl.Cardinality.Max != nil && uint32(len(ids)) > *decl.Cardinality.Max) { + return nil, nil, nil, fmt.Sprintf("event %d: binding cardinality violated", i), nil + } + for _, id := range ids { + if invalid, err := w.admit(ctx, id, &decl); err != nil { + return nil, nil, nil, "", err + } else if invalid != "" { + return nil, nil, nil, fmt.Sprintf("event %d: %s", i, invalid), nil + } + } + refs = append(refs, ids...) + } + u := session.UncommittedEvent{Type: te.Type, RecordedAtUnixMilli: te.RecordedAtUnixMilli, SourceSeqs: append([]session.Seq(nil), te.SourceSeqs...), Ignorable: def.Ignorable, Payload: payload} + if err := session.ValidateUncommitted(&u); err != nil { + return nil, nil, nil, fmt.Sprintf("event %d: %v", i, err), nil + } + uncommitted[i] = u + rows[i] = session.SessionEvent{Seq: w.head.Next + session.Seq(i), CommitID: group.CommitID, Index: uint16(i), Last: i == len(group.Events)-1, + Type: u.Type, RecordedAtUnixMilli: u.RecordedAtUnixMilli, SourceSeqs: u.SourceSeqs, Ignorable: u.Ignorable, Payload: u.Payload} + } + return rows, uncommitted, refs, "", nil +} + +func (w *writer) admit(ctx context.Context, id artifact.BindingID, decl *BindingReferenceDefinition) (string, error) { + if w.admission.Bindings == nil { + return "event references artifacts but no binding resolver is configured", nil + } + binding, err := w.admission.Bindings.ResolveBinding(ctx, id) + if err != nil { + var aerr *artifact.Error + if errors.As(err, &aerr) { + return fmt.Sprintf("binding %s: %v", id, aerr), nil + } + return "", err + } + if len(decl.AllowedSchemes) > 0 { + allowed := false + for _, s := range decl.AllowedSchemes { + if s == binding.Ref.Scheme { + allowed = true + } + } + if !allowed { + return fmt.Sprintf("binding %s: scheme %s not allowed", id, binding.Ref.Scheme), nil + } + } + if binding.Ref.Durability.Rank() < decl.RequiredDurability.Rank() { + return fmt.Sprintf("binding %s: durability %s below required %s", id, binding.Ref.Durability, decl.RequiredDurability), nil + } + return "", nil +} + +// claim activates the retention claim before Append (EXT-WRT-3). +func (w *writer) claim(ctx context.Context, commitID session.CommitID, refs []artifact.BindingID) (*artifact.RetentionClaim, string, error) { + if w.admission.Ledger == nil { + return nil, "group references artifacts but no ledger is configured", nil + } + set, err := artifact.SetBuilder{Resolver: w.admission.Bindings}.Build(ctx, refs) + if err != nil { + var aerr *artifact.Error + if errors.As(err, &aerr) { + return nil, "binding set: " + aerr.Error(), nil + } + return nil, "", err + } + id := DeriveClaimID(w.registry.ProtocolVersion, w.sid, commitID, set.RefSetDigest) + claim, err := w.admission.Ledger.Activate(ctx, id, CommitOwner(w.sid, commitID), set) + if err != nil { + var aerr *artifact.Error + if errors.As(err, &aerr) { + return nil, "claim: " + aerr.Error(), nil + } + return nil, "", err + } + return &claim, "", nil +} + +type fingerprintRow struct { + Type session.EventType `json:"type"` + SourceSeqs []session.Seq `json:"sourceSeqs,omitempty"` + Payload string `json:"payload"` +} + +// fingerprintRows covers what makes a retry "the same group": CommitID, +// Types, SourceSeqs and payloads, never timestamps (EXT-WRT-2). +func fingerprintRows(sid session.SessionID, rows []session.SessionEvent) (es.Digest, error) { + body := struct { + SessionID session.SessionID `json:"sessionId"` + CommitID session.CommitID `json:"commitId"` + Rows []fingerprintRow `json:"rows"` + }{SessionID: sid, CommitID: rows[0].CommitID, Rows: make([]fingerprintRow, len(rows))} + for i, r := range rows { + body.Rows[i] = fingerprintRow{r.Type, r.SourceSeqs, r.Payload.String()} + } + raw, err := es.EncodeTypedPayload(session.ProtocolVersion1, "twilight/session-extension/fingerprint", body) + if err != nil { + return "", err + } + return es.DigestBytes(raw), nil +} + +// --- writers ------------------------------------------------------------------------- + +type writers struct { + store session.Store + registry *Registry + admission Admission + opts session.OpenOptions + mu sync.Mutex + open map[session.SessionID]Writer +} + +// NewWriters returns a Writers that opens each Session once and hands out the +// same Writer afterwards (EXT-WRT-6). +func NewWriters(store session.Store, registry *Registry, admission Admission, opts session.OpenOptions) Writers { + return &writers{store: store, registry: registry, admission: admission, opts: opts, open: make(map[session.SessionID]Writer)} +} + +func (ws *writers) Writer(ctx context.Context, sid session.SessionID) (Writer, error) { + ws.mu.Lock() + defer ws.mu.Unlock() + if w, ok := ws.open[sid]; ok { + if lw, ok := w.(*writer); ok && lw.lost != nil { + return nil, lw.lost + } + return w, nil + } + w, err := OpenWriter(ctx, ws.store, ws.registry, ws.admission, sid, ws.opts) + if err != nil { + return nil, err + } + ws.open[sid] = w + return w, nil +} + +// Close closes every open Writer and forgets it; a later Writer(sid) reopens. +func (ws *writers) Close(ctx context.Context) error { + ws.mu.Lock() + defer ws.mu.Unlock() + var first error + for sid, w := range ws.open { + if err := w.Close(ctx); err != nil && first == nil { + first = err + } + delete(ws.open, sid) + } + return first +} + +// CloseWriters closes and forgets every Writer of a NewWriters value. +func CloseWriters(ctx context.Context, ws Writers) error { + if c, ok := ws.(interface{ Close(context.Context) error }); ok { + return c.Close(ctx) + } + return nil +} diff --git a/agent/session/filestore/conformance_test.go b/agent/session/filestore/conformance_test.go new file mode 100644 index 0000000..b93cb62 --- /dev/null +++ b/agent/session/filestore/conformance_test.go @@ -0,0 +1,30 @@ +package filestore_test + +import ( + "testing" + + "github.com/felinics/twilight/agent/session/filestore" + "github.com/felinics/twilight/agent/session/run/runtimetest" + "github.com/felinics/twilight/agent/session/sessiontest" +) + +func newStore(t testing.TB) *filestore.Store { + t.Helper() + store, err := filestore.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return store +} + +func TestKernelConformance(t *testing.T) { + sessiontest.Run(t, func(t *testing.T) sessiontest.Fixture { + return sessiontest.Fixture{Store: newStore(t)} + }) +} + +func TestRuntimeConformance(t *testing.T) { + runtimetest.Run(t, func(t testing.TB) runtimetest.Fixture { + return runtimetest.Fixture{Store: newStore(t)} + }) +} diff --git a/agent/session/filestore/filestore.go b/agent/session/filestore/filestore.go new file mode 100644 index 0000000..b6ba5bf --- /dev/null +++ b/agent/session/filestore/filestore.go @@ -0,0 +1,515 @@ +// Package filestore is the JSONL-backed session.Store: one directory per +// Session holding header.json, log.jsonl (one committed row per line) and +// owner.json (writer ownership: epoch and owned flag). The log is plain JSONL +// so a stream can be inspected and diffed with standard tools. +// +// Ownership is arbitrated through owner.json, so two Store instances over the +// same root behave as two processes: a takeover through one instance fences +// the other instance's writer on its next Append. Instances inside one +// process serialize through the store lock only — the adapter takes no +// cross-process file locks, so run at most one process per root at a time. +package filestore + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "unicode/utf8" + + "github.com/felinics/twilight/agent/session" +) + +const ( + headerFile = "header.json" + logFile = "log.jsonl" + ownerFile = "owner.json" +) + +// Store is the JSONL session.Store. +type Store struct { + root string + profile session.ProtocolProfile + mu sync.Mutex // serializes every operation of this instance +} + +// New opens the store root, creating it if needed. +func New(root string) (*Store, error) { + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, err + } + return &Store{root: root, profile: session.ProfileV1()}, nil +} + +// LogPath returns the Session's JSONL log file for direct inspection. +func (s *Store) LogPath(sid session.SessionID) string { + return filepath.Join(s.dir(sid), logFile) +} + +func (s *Store) dir(sid session.SessionID) string { + return filepath.Join(s.root, encodeID(string(sid))) +} + +// encodeID maps a SessionID to a safe file name: [A-Za-z0-9._-] bytes stay, +// every other byte is percent-encoded; "." and ".." are fully encoded. +func encodeID(id string) string { + if id == "." || id == ".." { + return strings.Repeat("%2E", len(id)) + } + var b strings.Builder + for i := 0; i < len(id); i++ { + c := id[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '_', c == '-': + b.WriteByte(c) + default: + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +func kerr(code session.ErrorCode, op string, sid session.SessionID, detail string) error { + return &session.Error{Code: code, Operation: op, SessionID: sid, Detail: detail} +} + +// --- header --------------------------------------------------------------------- + +func readHeader(dir string) (session.SessionHeader, error) { + raw, err := os.ReadFile(filepath.Join(dir, headerFile)) + if err != nil { + return session.SessionHeader{}, err + } + var h session.SessionHeader + if err := json.Unmarshal(raw, &h); err != nil { + return session.SessionHeader{}, fmt.Errorf("%s: %w", headerFile, err) + } + return h, nil +} + +func (s *Store) loadHeader(sid session.SessionID, op string) (session.SessionHeader, string, error) { + dir := s.dir(sid) + h, err := readHeader(dir) + if err != nil { + if os.IsNotExist(err) { + return session.SessionHeader{}, "", kerr(session.ErrNotFound, op, sid, "session not found") + } + return session.SessionHeader{}, "", kerr(session.ErrCorrupt, op, sid, err.Error()) + } + if err := s.profile.ValidateHeader(h); err != nil { + return session.SessionHeader{}, "", err + } + return h, dir, nil +} + +func (s *Store) Create(ctx context.Context, req session.CreateRequest) (session.SessionHeader, error) { + if err := ctx.Err(); err != nil { + return session.SessionHeader{}, err + } + if req.ProtocolVersion != s.profile.Version() { + return session.SessionHeader{}, kerr(session.ErrUnsupportedProfile, "create", req.SessionID, "") + } + header := session.SessionHeader{ProtocolVersion: req.ProtocolVersion, SessionID: req.SessionID, CreatedAtUnixMilli: req.CreatedAtUnixMilli, CausationID: req.CausationID, Metadata: req.Metadata} + digest, err := s.profile.HeaderDigest(header) + if err != nil { + return session.SessionHeader{}, err + } + header.HeaderDigest = digest + if err := s.profile.ValidateHeader(header); err != nil { + return session.SessionHeader{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + dir := s.dir(req.SessionID) + existing, err := readHeader(dir) + switch { + case err == nil: + if existing.HeaderDigest == header.HeaderDigest { + return existing, nil + } + return session.SessionHeader{}, kerr(session.ErrConflict, "create", req.SessionID, "session exists with a different header") + case !os.IsNotExist(err): + return session.SessionHeader{}, kerr(session.ErrCorrupt, "create", req.SessionID, err.Error()) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return session.SessionHeader{}, err + } + raw, err := json.Marshal(header) + if err != nil { + return session.SessionHeader{}, err + } + if err := writeAtomic(filepath.Join(dir, headerFile), raw); err != nil { + return session.SessionHeader{}, err + } + return header, nil +} + +func (s *Store) Header(ctx context.Context, sid session.SessionID) (session.SessionHeader, error) { + if err := ctx.Err(); err != nil { + return session.SessionHeader{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + h, _, err := s.loadHeader(sid, "header") + return h, err +} + +// --- ownership ------------------------------------------------------------------ + +// ownerRecord is the persisted ownership state; owner.json is the authority +// that every Append and Close checks against. +type ownerRecord struct { + Epoch session.Epoch `json:"epoch"` + Owned bool `json:"owned"` +} + +func loadOwner(dir string) (ownerRecord, error) { + raw, err := os.ReadFile(filepath.Join(dir, ownerFile)) + if err != nil { + if os.IsNotExist(err) { + return ownerRecord{}, nil + } + return ownerRecord{}, err + } + var rec ownerRecord + if err := json.Unmarshal(raw, &rec); err != nil { + return ownerRecord{}, fmt.Errorf("%s: %w", ownerFile, err) + } + return rec, nil +} + +func saveOwner(dir string, rec ownerRecord) error { + raw, err := json.Marshal(rec) + if err != nil { + return err + } + return writeAtomic(filepath.Join(dir, ownerFile), raw) +} + +func (s *Store) Open(ctx context.Context, sid session.SessionID, opts session.OpenOptions) (session.Writer, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + header, dir, err := s.loadHeader(sid, "open") + if err != nil { + return nil, err + } + rec, err := loadOwner(dir) + if err != nil { + return nil, err + } + if rec.Owned && !opts.Takeover { + return nil, kerr(session.ErrOwned, "open", sid, fmt.Sprintf("owned by epoch %d", rec.Epoch)) + } + logPath := filepath.Join(dir, logFile) + rows, retained, torn, err := readLog(logPath, sid, "open") + if err != nil { + return nil, err + } + if err := session.ValidateChain(s.profile, header, rows); err != nil { + return nil, err + } + // A torn tail — a partial line or a group whose Last row never landed — + // is the remnant of a crashed append; the new owner truncates it so the + // stream continues from the last complete group. + if torn { + if err := os.Truncate(logPath, retained); err != nil { + return nil, err + } + } + rec.Epoch++ + rec.Owned = true + if err := saveOwner(dir, rec); err != nil { + return nil, err + } + w := &fileWriter{store: s, header: header, dir: dir, logPath: logPath, epoch: rec.Epoch, + head: headOf(header, rows), commits: make(map[session.CommitID]struct{}, len(rows))} + for i := range rows { + w.commits[rows[i].CommitID] = struct{}{} + } + return w, nil +} + +func headOf(h session.SessionHeader, rows []session.SessionEvent) session.Head { + if len(rows) == 0 { + return session.Head{Next: 0, Digest: h.HeaderDigest} + } + last := &rows[len(rows)-1] + return session.Head{Next: last.Seq + 1, Digest: last.Digest} +} + +type fileWriter struct { + store *Store + header session.SessionHeader + dir string + logPath string + epoch session.Epoch + head session.Head + commits map[session.CommitID]struct{} +} + +func (w *fileWriter) SessionID() session.SessionID { return w.header.SessionID } +func (w *fileWriter) Epoch() session.Epoch { return w.epoch } + +func (w *fileWriter) Head() session.Head { + w.store.mu.Lock() + defer w.store.mu.Unlock() + return w.head +} + +// current re-reads owner.json: the file is the ownership authority, so a +// takeover through another Store instance fences this writer. The caller +// holds the store lock. +func (w *fileWriter) current(op string) error { + rec, err := loadOwner(w.dir) + if err != nil { + return err + } + if !rec.Owned || rec.Epoch != w.epoch { + return kerr(session.ErrOwnershipLost, op, w.header.SessionID, fmt.Sprintf("epoch %d superseded by %d", w.epoch, rec.Epoch)) + } + return nil +} + +func (w *fileWriter) Close(ctx context.Context) error { + w.store.mu.Lock() + defer w.store.mu.Unlock() + rec, err := loadOwner(w.dir) + if err != nil { + return err + } + if rec.Owned && rec.Epoch == w.epoch { + return saveOwner(w.dir, ownerRecord{Epoch: w.epoch}) + } + return nil // closing a superseded writer is a no-op +} + +// --- append --------------------------------------------------------------------- + +func (w *fileWriter) Append(ctx context.Context, g session.Group) ([]session.SessionEvent, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + sid := w.header.SessionID + if g.CommitID == "" { + return nil, kerr(session.ErrInvalid, "append", sid, "empty CommitID") + } + if !utf8.ValidString(string(g.CommitID)) { + return nil, kerr(session.ErrInvalid, "append", sid, "CommitID is not valid UTF-8") + } + if len(g.Events) == 0 { + return nil, kerr(session.ErrInvalid, "append", sid, "empty group") + } + if len(g.Events) > int(^uint16(0)) { + return nil, kerr(session.ErrInvalid, "append", sid, "group too large") + } + for i := range g.Events { + if err := session.ValidateUncommitted(&g.Events[i]); err != nil { + return nil, kerr(session.ErrInvalid, "append", sid, fmt.Sprintf("event %d: %v", i, err)) + } + } + w.store.mu.Lock() + defer w.store.mu.Unlock() + if err := w.current("append"); err != nil { + return nil, err + } + if _, dup := w.commits[g.CommitID]; dup { + return nil, &session.Error{Code: session.ErrConflict, Operation: "append", SessionID: sid, CommitID: g.CommitID, Detail: "CommitID already in stream"} + } + prev := w.head.Digest + rows := make([]session.SessionEvent, len(g.Events)) + var buf bytes.Buffer + for i := range g.Events { + e := &g.Events[i] + row := session.SessionEvent{Seq: w.head.Next + session.Seq(i), CommitID: g.CommitID, Index: uint16(i), Last: i == len(g.Events)-1, + Type: e.Type, RecordedAtUnixMilli: e.RecordedAtUnixMilli, SourceSeqs: append([]session.Seq(nil), e.SourceSeqs...), Ignorable: e.Ignorable, Payload: e.Payload} + d, err := w.store.profile.EventDigest(prev, sid, row) + if err != nil { + return nil, err + } + row.Digest = d + prev = d + rows[i] = row + line, err := json.Marshal(row) + if err != nil { + return nil, err + } + buf.Write(line) + buf.WriteByte('\n') + } + // The whole group goes down in one write so a crash can only tear the + // tail, which the next Open truncates. + f, err := os.OpenFile(w.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, err + } + if _, err := f.Write(buf.Bytes()); err != nil { + f.Close() + return nil, err + } + if err := f.Sync(); err != nil { + f.Close() + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + w.head = session.Head{Next: rows[len(rows)-1].Seq + 1, Digest: prev} + w.commits[g.CommitID] = struct{}{} + return rows, nil +} + +// --- read ----------------------------------------------------------------------- + +// readLog parses log.jsonl. A torn tail — a final line without its newline, a +// final line that does not parse, or trailing rows of a group whose Last row +// never landed — is excluded; retained is the byte length of the retained +// prefix and torn reports whether anything was excluded. Malformed content +// before the final line is ErrCorrupt. +func readLog(path string, sid session.SessionID, op string) (rows []session.SessionEvent, retained int64, torn bool, err error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, 0, false, nil + } + return nil, 0, false, err + } + var offsets []int64 + off := 0 + for off < len(data) { + nl := bytes.IndexByte(data[off:], '\n') + if nl < 0 { + torn = true // unterminated tail line + break + } + line := data[off : off+nl] + var row session.SessionEvent + if uerr := json.Unmarshal(line, &row); uerr != nil { + if off+nl+1 == len(data) { + torn = true // torn write of the final line + break + } + return nil, 0, false, kerr(session.ErrCorrupt, op, sid, fmt.Sprintf("row at byte %d: %v", off, uerr)) + } + rows = append(rows, row) + offsets = append(offsets, int64(off)) + off += nl + 1 + } + retained = int64(off) + for len(rows) > 0 && !rows[len(rows)-1].Last { + rows = rows[:len(rows)-1] + retained = offsets[len(rows)] + torn = true + } + return rows, retained, torn, nil +} + +func (s *Store) Read(ctx context.Context, req session.ReadRequest) (session.ReadPage, error) { + if err := ctx.Err(); err != nil { + return session.ReadPage{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + header, dir, err := s.loadHeader(req.SessionID, "read") + if err != nil { + return session.ReadPage{}, err + } + rows, _, _, err := readLog(filepath.Join(dir, logFile), req.SessionID, "read") + if err != nil { + return session.ReadPage{}, err + } + page := session.ReadPage{Header: header, Head: headOf(header, rows)} + if req.From > session.Seq(len(rows)) { + return page, nil + } + // Start at a group boundary at or before From so no partial group leaks. + start := int(req.From) + for start > 0 && start < len(rows) && rows[start].Index != 0 { + start-- + } + for i := start; i < len(rows); { + end := i + for end < len(rows) && !rows[end].Last { + end++ + } + if end >= len(rows) { + break // incomplete tail group is never exposed (SES-APP-2) + } + var matched []session.SessionEvent + for j := i; j <= end; j++ { + if rows[j].Seq >= req.From && session.HasTypePrefix(rows[j].Type, req.Types) { + matched = append(matched, rows[j]) + } + } + if len(matched) > 0 { + // Limit counts rows but only truncates between groups; the first + // group is always returned so a caller can make progress. + if req.Limit > 0 && len(page.Events) > 0 && len(page.Events)+len(matched) > int(req.Limit) { + page.HasMore = true + break + } + page.Events = append(page.Events, matched...) + } + i = end + 1 + } + return page, nil +} + +// Tamper rewrites one row on disk so conformance can prove the chain check at +// Open detects corruption; production code never calls it. +func (s *Store) Tamper(sid session.SessionID, seq session.Seq, mutate func(*session.SessionEvent)) { + s.mu.Lock() + defer s.mu.Unlock() + _, dir, err := s.loadHeader(sid, "tamper") + if err != nil { + return + } + path := filepath.Join(dir, logFile) + rows, _, _, err := readLog(path, sid, "tamper") + if err != nil || int(seq) >= len(rows) { + return + } + mutate(&rows[seq]) + var buf bytes.Buffer + for i := range rows { + line, err := json.Marshal(rows[i]) + if err != nil { + return + } + buf.Write(line) + buf.WriteByte('\n') + } + _ = writeAtomic(path, buf.Bytes()) +} + +// --- io helpers ----------------------------------------------------------------- + +func writeAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") + if err != nil { + return err + } + name := tmp.Name() + _, werr := tmp.Write(data) + serr := tmp.Sync() + cerr := tmp.Close() + for _, e := range []error{werr, serr, cerr} { + if e != nil { + os.Remove(name) + return e + } + } + if err := os.Rename(name, path); err != nil { + os.Remove(name) + return err + } + return nil +} + +var _ session.Store = (*Store)(nil) diff --git a/agent/session/filestore/frozen.go b/agent/session/filestore/frozen.go new file mode 100644 index 0000000..6522268 --- /dev/null +++ b/agent/session/filestore/frozen.go @@ -0,0 +1,91 @@ +package filestore + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/felinics/twilight/agent/es" +) + +// frozenDir is the store directory under the root. The leading "%" keeps it +// disjoint from every session directory: encodeID only ever emits "%" as part +// of a %XX escape, so no SessionID encodes to a name starting with "%f". +const frozenDir = "%frozen" + +// FrozenValues is the file-backed run.FrozenValueStore: one file per digest +// under /%frozen, written atomically. Two instances over the same root +// see each other's bodies, so a restarted process can replay the frozen +// request of an interrupted ModelStep (RUN-WIR-4). +type FrozenValues struct { + dir string +} + +// NewFrozenValues opens (creating if needed) the frozen-value store under +// root. The root may be shared with New's session store. +func NewFrozenValues(root string) (*FrozenValues, error) { + if root == "" { + return nil, errors.New("filestore: frozen values: empty root") + } + dir := filepath.Join(root, frozenDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("filestore: frozen values: %w", err) + } + return &FrozenValues{dir: dir}, nil +} + +func (f *FrozenValues) path(digest es.Digest) string { + return filepath.Join(f.dir, encodeID(string(digest))) +} + +// Put stores one body under its digest. It is idempotent; a re-Put whose +// bytes differ from the stored file reports corruption instead of silently +// keeping either copy (the digest names the content). +func (f *FrozenValues) Put(ctx context.Context, digest es.Digest, value []byte) error { + if err := ctx.Err(); err != nil { + return err + } + if digest == "" { + return errors.New("filestore: frozen values: empty digest") + } + path := f.path(digest) + if existing, err := os.ReadFile(path); err == nil { + if !bytes.Equal(existing, value) { + return fmt.Errorf("filestore: frozen values: stored body for %s differs from the new value", digest) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return writeAtomic(path, value) +} + +// Get returns the stored body; a missing digest is (nil, false, nil). +func (f *FrozenValues) Get(ctx context.Context, digest es.Digest) ([]byte, bool, error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + if digest == "" { + return nil, false, nil + } + raw, err := os.ReadFile(f.path(digest)) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return raw, true, nil +} + +// Delete drops one body; the Runtime calls it when a withdrawn step ends the +// body's useful life. Best effort: a missing file is already deleted. +func (f *FrozenValues) Delete(digest es.Digest) { + if digest == "" { + return + } + _ = os.Remove(f.path(digest)) +} diff --git a/agent/session/filestore/frozen_test.go b/agent/session/filestore/frozen_test.go new file mode 100644 index 0000000..6c35db5 --- /dev/null +++ b/agent/session/filestore/frozen_test.go @@ -0,0 +1,65 @@ +package filestore + +import ( + "context" + "testing" + + "github.com/felinics/twilight/agent/run" +) + +var _ run.FrozenValueStore = (*FrozenValues)(nil) + +// TestFrozenValues covers the store contract: round trip, idempotent re-put, +// conflicting re-put, missing digest, delete, and two instances over one root +// (the process-restart path RecoverModelExecution depends on). +func TestFrozenValues(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + fv, err := NewFrozenValues(root) + if err != nil { + t.Fatal(err) + } + const digest = run.Digest("sha256:0123abcd") + body := []byte(`{"model":"m-1"}`) + + puts := []struct { + name string + digest run.Digest + value []byte + wantErr bool + }{ + {"first put", digest, body, false}, + {"idempotent re-put", digest, body, false}, + {"conflicting re-put", digest, []byte(`{"model":"other"}`), true}, + {"empty digest", "", body, true}, + } + for _, tc := range puts { + if err := fv.Put(ctx, tc.digest, tc.value); (err != nil) != tc.wantErr { + t.Fatalf("%s: err = %v, wantErr %v", tc.name, err, tc.wantErr) + } + } + + got, ok, err := fv.Get(ctx, digest) + if err != nil || !ok || string(got) != string(body) { + t.Fatalf("get = %q %v %v", got, ok, err) + } + if _, ok, err := fv.Get(ctx, "sha256:missing"); err != nil || ok { + t.Fatalf("missing digest = %v %v, want (false, nil)", ok, err) + } + + // A second instance over the same root is the restarted process. + fv2, err := NewFrozenValues(root) + if err != nil { + t.Fatal(err) + } + got2, ok, err := fv2.Get(ctx, digest) + if err != nil || !ok || string(got2) != string(body) { + t.Fatalf("second instance get = %q %v %v", got2, ok, err) + } + + fv2.Delete(digest) + if _, ok, _ := fv.Get(ctx, digest); ok { + t.Fatal("deleted digest still readable through the first instance") + } + fv2.Delete(digest) // deleting a missing body is a no-op +} diff --git a/agent/session/memory.go b/agent/session/memory.go new file mode 100644 index 0000000..a04d0e8 --- /dev/null +++ b/agent/session/memory.go @@ -0,0 +1,277 @@ +package session + +import ( + "context" + "fmt" + "sync" +) + +// MemoryStore is the in-process reference Store. Ownership lasts until Close; +// an Open with Takeover supersedes a live owner, which is then fenced by its +// stale Epoch. +type MemoryStore struct { + profile ProtocolProfile + mu sync.RWMutex // guards sessions map + sessions map[SessionID]*memorySession +} + +type memorySession struct { + mu sync.Mutex + header SessionHeader + rows []SessionEvent + byCommit map[CommitID][2]int // [first, last] row index of the group + epoch Epoch + owner *memoryWriter // nil when no live owner +} + +// NewMemoryStore returns an empty MemoryStore. +func NewMemoryStore() *MemoryStore { + return &MemoryStore{profile: ProfileV1(), sessions: make(map[SessionID]*memorySession)} +} + +func (m *MemoryStore) Profile() ProtocolProfile { return m.profile } + +func (m *MemoryStore) session(sid SessionID, op string) (*memorySession, error) { + m.mu.RLock() + s, ok := m.sessions[sid] + m.mu.RUnlock() + if !ok { + return nil, newError(ErrNotFound, op, sid, "session not found") + } + return s, nil +} + +func (m *MemoryStore) Create(ctx context.Context, req CreateRequest) (SessionHeader, error) { + if err := ctx.Err(); err != nil { + return SessionHeader{}, err + } + if req.ProtocolVersion != m.profile.Version() { + return SessionHeader{}, &Error{Code: ErrUnsupportedProfile, Operation: "create", SessionID: req.SessionID} + } + header := SessionHeader{ProtocolVersion: req.ProtocolVersion, SessionID: req.SessionID, CreatedAtUnixMilli: req.CreatedAtUnixMilli, CausationID: req.CausationID, Metadata: req.Metadata} + digest, err := m.profile.HeaderDigest(header) + if err != nil { + return SessionHeader{}, err + } + header.HeaderDigest = digest + if err := m.profile.ValidateHeader(header); err != nil { + return SessionHeader{}, err + } + m.mu.Lock() + defer m.mu.Unlock() + if existing, ok := m.sessions[req.SessionID]; ok { + if existing.header.HeaderDigest == header.HeaderDigest { + return existing.header, nil + } + return SessionHeader{}, newError(ErrConflict, "create", req.SessionID, "session exists with a different header") + } + m.sessions[req.SessionID] = &memorySession{header: header, byCommit: make(map[CommitID][2]int)} + return header, nil +} + +func (m *MemoryStore) Header(ctx context.Context, sid SessionID) (SessionHeader, error) { + if err := ctx.Err(); err != nil { + return SessionHeader{}, err + } + s, err := m.session(sid, "header") + if err != nil { + return SessionHeader{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.header, nil +} + +func (s *memorySession) head() Head { + if len(s.rows) == 0 { + return Head{Next: 0, Digest: s.header.HeaderDigest} + } + last := &s.rows[len(s.rows)-1] + return Head{Next: last.Seq + 1, Digest: last.Digest} +} + +// --- ownership ------------------------------------------------------------------ + +type memoryWriter struct { + store *MemoryStore + s *memorySession + epoch Epoch +} + +func (m *MemoryStore) Open(ctx context.Context, sid SessionID, opts OpenOptions) (Writer, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s, err := m.session(sid, "open") + if err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.owner != nil && !opts.Takeover { + return nil, newError(ErrOwned, "open", sid, fmt.Sprintf("owned by epoch %d", s.epoch)) + } + // Corruption detection happens here, before ownership is established + // (SES-REP-1); Read trusts the store. + if err := ValidateChain(m.profile, s.header, s.rows); err != nil { + return nil, err + } + s.epoch++ + w := &memoryWriter{store: m, s: s, epoch: s.epoch} + s.owner = w + return w, nil +} + +func (w *memoryWriter) SessionID() SessionID { return w.s.header.SessionID } +func (w *memoryWriter) Epoch() Epoch { return w.epoch } + +func (w *memoryWriter) Head() Head { + w.s.mu.Lock() + defer w.s.mu.Unlock() + return w.s.head() +} + +// current reports whether w still owns the stream; the caller holds s.mu. +func (w *memoryWriter) current(op string) error { + if w.s.owner != w || w.s.epoch != w.epoch { + return newError(ErrOwnershipLost, op, w.s.header.SessionID, fmt.Sprintf("epoch %d superseded by %d", w.epoch, w.s.epoch)) + } + return nil +} + +func (w *memoryWriter) Close(ctx context.Context) error { + w.s.mu.Lock() + defer w.s.mu.Unlock() + if w.s.owner == w { + w.s.owner = nil + } + return nil +} + +// --- append ----------------------------------------------------------------------- + +func (w *memoryWriter) Append(ctx context.Context, g Group) ([]SessionEvent, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + sid := w.s.header.SessionID + if g.CommitID == "" { + return nil, newError(ErrInvalid, "append", sid, "empty CommitID") + } + if err := validIdentity("CommitID", string(g.CommitID)); err != nil { + return nil, newError(ErrInvalid, "append", sid, err.Error()) + } + if len(g.Events) == 0 { + return nil, newError(ErrInvalid, "append", sid, "empty group") + } + if len(g.Events) > int(^uint16(0)) { + return nil, newError(ErrInvalid, "append", sid, "group too large") + } + for i := range g.Events { + if err := ValidateUncommitted(&g.Events[i]); err != nil { + return nil, newError(ErrInvalid, "append", sid, fmt.Sprintf("event %d: %v", i, err)) + } + } + w.s.mu.Lock() + defer w.s.mu.Unlock() + if err := w.current("append"); err != nil { + return nil, err + } + if _, dup := w.s.byCommit[g.CommitID]; dup { + return nil, &Error{Code: ErrConflict, Operation: "append", SessionID: sid, CommitID: g.CommitID, Detail: "CommitID already in stream"} + } + head := w.s.head() + prev := head.Digest + rows := make([]SessionEvent, len(g.Events)) + for i := range g.Events { + e := &g.Events[i] + row := SessionEvent{Seq: head.Next + Seq(i), CommitID: g.CommitID, Index: uint16(i), Last: i == len(g.Events)-1, + Type: e.Type, RecordedAtUnixMilli: e.RecordedAtUnixMilli, SourceSeqs: append([]Seq(nil), e.SourceSeqs...), Ignorable: e.Ignorable, Payload: e.Payload} + d, err := w.store.profile.EventDigest(prev, sid, row) + if err != nil { + return nil, err + } + row.Digest = d + prev = d + rows[i] = row + } + first := len(w.s.rows) + w.s.rows = append(w.s.rows, rows...) + w.s.byCommit[g.CommitID] = [2]int{first, first + len(rows) - 1} + return cloneRows(rows), nil +} + +// --- read -------------------------------------------------------------------------- + +func (m *MemoryStore) Read(ctx context.Context, req ReadRequest) (ReadPage, error) { + if err := ctx.Err(); err != nil { + return ReadPage{}, err + } + s, err := m.session(req.SessionID, "read") + if err != nil { + return ReadPage{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + page := ReadPage{Header: s.header, Head: s.head()} + if int(req.From) > len(s.rows) { + return page, nil + } + // Start at a group boundary at or before From so no partial group leaks. + start := int(req.From) + for start > 0 && start < len(s.rows) && s.rows[start].Index != 0 { + start-- + } + for i := start; i < len(s.rows); { + end := i + for end < len(s.rows) && !s.rows[end].Last { + end++ + } + if end >= len(s.rows) { + break // incomplete tail group is never exposed (SES-APP-2) + } + var matched []SessionEvent + for j := i; j <= end; j++ { + if s.rows[j].Seq >= req.From && HasTypePrefix(s.rows[j].Type, req.Types) { + matched = append(matched, s.rows[j]) + } + } + if len(matched) > 0 { + // Limit counts rows but only truncates between groups; the first + // group is always returned so a caller can make progress. + if req.Limit > 0 && len(page.Events) > 0 && len(page.Events)+len(matched) > int(req.Limit) { + page.HasMore = true + break + } + page.Events = append(page.Events, cloneRows(matched)...) + } + i = end + 1 + } + return page, nil +} + +// Tamper mutates one stored row in place. It exists so conformance can prove +// that the chain check at Open detects corruption; production code never +// calls it. +func (m *MemoryStore) Tamper(sid SessionID, seq Seq, mutate func(*SessionEvent)) { + s, err := m.session(sid, "tamper") + if err != nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if int(seq) < len(s.rows) { + mutate(&s.rows[seq]) + } +} + +func cloneRows(rows []SessionEvent) []SessionEvent { + out := make([]SessionEvent, len(rows)) + for i := range rows { + out[i] = rows[i] + out[i].SourceSeqs = append([]Seq(nil), rows[i].SourceSeqs...) + } + return out +} + +var _ Store = (*MemoryStore)(nil) diff --git a/agent/session/memory_test.go b/agent/session/memory_test.go new file mode 100644 index 0000000..5901b87 --- /dev/null +++ b/agent/session/memory_test.go @@ -0,0 +1,14 @@ +package session_test + +import ( + "testing" + + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/sessiontest" +) + +func TestMemoryStoreConformance(t *testing.T) { + sessiontest.Run(t, func(t *testing.T) sessiontest.Fixture { + return sessiontest.Fixture{Store: session.NewMemoryStore()} + }) +} diff --git a/agent/session/profile.go b/agent/session/profile.go new file mode 100644 index 0000000..a6a3cbd --- /dev/null +++ b/agent/session/profile.go @@ -0,0 +1,151 @@ +package session + +import ( + "bytes" + "errors" + "fmt" + "unicode/utf8" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/jsonstable" +) + +// ProtocolProfile freezes the kernel wire for one ProtocolVersion +// (SES-WIR-2): the header digest and the per-row chained digest. +type ProtocolProfile interface { + Version() uint16 + HeaderDigest(SessionHeader) (es.Digest, error) + // EventDigest computes a row's Digest given the previous row's Digest + // (HeaderDigest for Seq 0). The row's own Digest field is ignored. + EventDigest(prev es.Digest, sid SessionID, row SessionEvent) (es.Digest, error) + ValidateHeader(SessionHeader) error +} + +// ProfileV1 returns the ProtocolVersion1 profile. +func ProfileV1() ProtocolProfile { return profileV1{} } + +// ProfileFor returns the profile bound to version. +func ProfileFor(version uint16) (ProtocolProfile, error) { + if version == ProtocolVersion1 { + return profileV1{}, nil + } + return nil, &Error{Code: ErrUnsupportedProfile, Operation: "profile", Detail: fmt.Sprintf("protocol version %d", version)} +} + +type profileV1 struct{} + +func (profileV1) Version() uint16 { return ProtocolVersion1 } + +type headerDigestBody struct { + ProtocolVersion uint16 `json:"protocolVersion"` + SessionID SessionID `json:"sessionId"` + CreatedAtUnixMilli int64 `json:"createdAtUnixMilli"` + CausationID es.CausationID `json:"causationId,omitempty"` + Metadata jsonstable.Value `json:"metadata,omitempty"` +} + +func (profileV1) HeaderDigest(h SessionHeader) (es.Digest, error) { + if h.ParentFork != nil { + return "", &Error{Code: ErrUnsupported, Operation: "header", SessionID: h.SessionID, Detail: "fork is not in v1"} + } + return digestDomain("twilight/session/header", headerDigestBody{h.ProtocolVersion, h.SessionID, h.CreatedAtUnixMilli, h.CausationID, h.Metadata}) +} + +type eventDigestBody struct { + Prev es.Digest `json:"prev"` + SessionID SessionID `json:"sessionId"` + Seq Seq `json:"seq"` + CommitID CommitID `json:"commitId"` + Index uint16 `json:"index"` + Last bool `json:"last"` + Type EventType `json:"type"` + RecordedAtUnixMilli int64 `json:"recordedAtUnixMilli"` + SourceSeqs []Seq `json:"sourceSeqs,omitempty"` + Ignorable bool `json:"ignorable,omitempty"` + Payload jsonstable.Value `json:"payload"` +} + +func (profileV1) EventDigest(prev es.Digest, sid SessionID, e SessionEvent) (es.Digest, error) { + return digestDomain("twilight/session/event", eventDigestBody{prev, sid, e.Seq, e.CommitID, e.Index, e.Last, e.Type, e.RecordedAtUnixMilli, e.SourceSeqs, e.Ignorable, e.Payload}) +} + +func (p profileV1) ValidateHeader(h SessionHeader) error { + if h.ProtocolVersion != ProtocolVersion1 { + return &Error{Code: ErrUnsupportedProfile, Operation: "header", SessionID: h.SessionID} + } + if err := validIdentity("SessionID", string(h.SessionID)); err != nil { + return newError(ErrInvalid, "header", h.SessionID, err.Error()) + } + if h.ParentFork != nil { + return &Error{Code: ErrUnsupported, Operation: "header", SessionID: h.SessionID, Detail: "fork is not in v1"} + } + want, err := p.HeaderDigest(h) + if err != nil { + return err + } + if h.HeaderDigest != want { + return newError(ErrCorrupt, "header", h.SessionID, "header digest mismatch") + } + return nil +} + +// ValidateChain recomputes every row digest from the header and reports the +// first corrupt row (SES-REP-1). rows must start at Seq 0. +func ValidateChain(p ProtocolProfile, header SessionHeader, rows []SessionEvent) error { + prev := header.HeaderDigest + for i := range rows { + r := &rows[i] + if r.Seq != Seq(i) { + return &Error{Code: ErrCorrupt, Operation: "read", SessionID: header.SessionID, Detail: fmt.Sprintf("seq gap at %d", i)} + } + want, err := p.EventDigest(prev, header.SessionID, *r) + if err != nil { + return err + } + if r.Digest != want { + return &Error{Code: ErrCorrupt, Operation: "read", SessionID: header.SessionID, CommitID: r.CommitID, Detail: fmt.Sprintf("digest mismatch at seq %d", i)} + } + prev = r.Digest + } + return nil +} + +// ValidateUncommitted checks one event of a group before it is sealed: +// identities and a canonical JSON object payload (SES-WIR-1). +func ValidateUncommitted(e *UncommittedEvent) error { + if err := validIdentity("EventType", string(e.Type)); err != nil { + return err + } + if e.Payload.IsZero() { + return errors.New("empty payload") + } + canon, err := jsonstable.Canonicalize(e.Payload.Bytes()) + if err != nil { + return fmt.Errorf("payload: %w", err) + } + if !bytes.Equal(canon, e.Payload.Bytes()) { + return errors.New("payload is not canonical") + } + if !bytes.HasPrefix(bytes.TrimSpace(e.Payload.Bytes()), []byte("{")) { + return errors.New("payload is not an object") + } + return nil +} + +func validIdentity(name, v string) error { + if v == "" { + return fmt.Errorf("%s is empty", name) + } + if !utf8.ValidString(v) { + return fmt.Errorf("%s is not valid UTF-8", name) + } + return nil +} + +func digestDomain(domain string, body any) (es.Digest, error) { + raw, err := es.EncodeTypedPayload(ProtocolVersion1, domain, body) + if err != nil { + return "", err + } + return es.DigestBytes(raw), nil +} diff --git a/agent/session/run/module.go b/agent/session/run/module.go new file mode 100644 index 0000000..83ad34b --- /dev/null +++ b/agent/session/run/module.go @@ -0,0 +1,144 @@ +// Package runmod is the first-party Run Session Module (agent-run.md 5): the +// twilight/run/ EventDefinitions, the twilight/run/machine projection and the +// run.Runtime implementation over the Session Module Framework. +package runmod + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/extension" +) + +const ( + ModuleID extension.ModuleID = "run" + // Prefix is the EventType namespace of every Run fact. + Prefix session.EventType = "twilight/run/" +) + +// factNames is the closed list of v1 fact discriminators. +var factNames = []string{ + "run_created", "model_step_prepared", "model_step_withdrawn", "model_step_started", "model_step_recovered", + "model_step_rejected", "model_step_completed", "tool_step_opened", "tool_call_started", "tool_call_approved", + "tool_call_completed", "tool_call_answered", "tool_call_failed", "input_accepted", "run_ended", +} + +// EventType returns the EventType of a fact. +func EventType(f run.Fact) session.EventType { return Prefix + session.EventType(run.FactType(f)) } + +// Event is the typed value of one twilight/run/ event: the fact plus the +// RunID that every payload carries at its first level (RUN-WIR-2). +type Event struct { + RunID run.RunID + Fact run.Fact +} + +// factCodec encodes one fact type for one SchemaVersion. The payload is the +// canonical fact object with "runId" added; `v` is the Registry's. +type factCodec struct { + local string + proto run.Protocol +} + +func (c factCodec) Validate(value any) error { + ev, ok := value.(Event) + if !ok { + return fmt.Errorf("value is %T, want runmod.Event", value) + } + if ev.RunID == "" || ev.Fact == nil { + return errors.New("event requires runId and fact") + } + if run.FactType(ev.Fact) != c.local { + return fmt.Errorf("fact is %s, codec is %s", run.FactType(ev.Fact), c.local) + } + return nil +} + +func (c factCodec) Encode(value any) (jsonstable.Value, error) { + if err := c.Validate(value); err != nil { + return jsonstable.Value{}, err + } + ev := value.(Event) + raw, err := es.MarshalCanonical(ev.Fact) + if err != nil { + return jsonstable.Value{}, err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return jsonstable.Value{}, err + } + if m == nil { + m = map[string]json.RawMessage{} + } + if existing, has := m["runId"]; has { + // RunCreated already carries runId; it must agree. + var id run.RunID + if err := json.Unmarshal(existing, &id); err != nil || id != ev.RunID { + return jsonstable.Value{}, errors.New("fact runId disagrees with event runId") + } + } + m["runId"] = json.RawMessage(fmt.Sprintf("%q", string(ev.RunID))) + return jsonstable.FromValue(m) +} + +func (c factCodec) Decode(wire jsonstable.Value) (any, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(wire.Bytes(), &m); err != nil { + return nil, err + } + rawID, ok := m["runId"] + if !ok { + return nil, errors.New("run event has no runId") + } + var id run.RunID + if err := json.Unmarshal(rawID, &id); err != nil || id == "" { + return nil, errors.New("run event runId is not a string") + } + if c.local != "run_created" { + delete(m, "runId") + } + body, err := jsonstable.FromValue(m) + if err != nil { + return nil, err + } + fact, err := c.proto.DecodeFact(c.local, body.Bytes()) + if err != nil { + return nil, err + } + if created, ok := fact.(run.RunCreated); ok && created.RunID != id { + return nil, errors.New("run_created runId disagrees with payload runId") + } + return Event{RunID: id, Fact: fact}, nil +} + +// Module is the run ModuleDescriptor (RUN-SCP-2: no Requires; Companion is a +// constructor parameter, not a module dependency). +var Module = buildModule() + +func buildModule() extension.ModuleDescriptor { + m := extension.ModuleDescriptor{Source: extension.SourceTwilight, ID: ModuleID, Projections: []extension.ProjectionDefinition{MachineProjection}} + for _, name := range factNames { + m.Events = append(m.Events, extension.EventDefinition{ + Type: Prefix + session.EventType(name), + Current: extension.PayloadVersion(run.SchemaVersion1), + Codecs: map[extension.PayloadVersion]extension.PayloadCodec{ + extension.PayloadVersion(run.SchemaVersion1): factCodec{local: name, proto: run.ProtocolV1()}, + }, + }) + } + return m +} + +// AllTypes lists every registered twilight/run/ EventType. +func AllTypes() []session.EventType { + out := make([]session.EventType, len(factNames)) + for i, name := range factNames { + out[i] = Prefix + session.EventType(name) + } + return out +} diff --git a/agent/session/run/projection.go b/agent/session/run/projection.go new file mode 100644 index 0000000..c493ac2 --- /dev/null +++ b/agent/session/run/projection.go @@ -0,0 +1,186 @@ +package runmod + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/extension" +) + +const MachineProjectionID extension.ProjectionID = "twilight/run/machine" + +// Machine is the twilight/run/machine projection state (RUN-CMT-2): every +// non-terminal Run of the Session with its last event position and schema. +// Terminal Runs leave the projection; Record and the turn surface keep their +// results. Ended keeps only the RunIDs of terminated Runs so a second +// run_created for a used RunID is refused (RUN-NEW-1) without keeping state. +type Machine struct { + Active map[run.RunID]run.MachineState + Positions map[run.RunID]run.RunPosition + Schemas map[run.RunID]uint16 + Ended map[run.RunID]struct{} +} + +func newMachine() Machine { + return Machine{Active: map[run.RunID]run.MachineState{}, Positions: map[run.RunID]run.RunPosition{}, Schemas: map[run.RunID]uint16{}, Ended: map[run.RunID]struct{}{}} +} + +func (m Machine) clone() Machine { + out := newMachine() + for k, v := range m.Active { + out.Active[k] = v + } + for k, v := range m.Positions { + out.Positions[k] = v + } + for k, v := range m.Schemas { + out.Schemas[k] = v + } + for k := range m.Ended { + out.Ended[k] = struct{}{} + } + return out +} + +// Apply folds one decoded run event (RUN-MCH-3 via Protocol.Evolve). +func (m Machine) Apply(e extension.DecodedEvent) (Machine, error) { + ev, ok := e.Value.(Event) + if !ok { + return m, fmt.Errorf("run machine: unexpected %T", e.Value) + } + out := m.clone() + var proto run.Protocol + var state run.MachineState + if created, isCreated := ev.Fact.(run.RunCreated); isCreated { + if _, dup := out.Active[ev.RunID]; dup { + return m, fmt.Errorf("run machine: %s created twice", ev.RunID) + } + if _, ended := out.Ended[ev.RunID]; ended { + return m, fmt.Errorf("run machine: %s created again after it ended", ev.RunID) + } + p, err := run.ProtocolFor(created.SchemaVersion) + if err != nil { + return m, err + } + proto = p + out.Schemas[ev.RunID] = created.SchemaVersion + } else { + cur, active := out.Active[ev.RunID] + if !active { + return m, fmt.Errorf("run machine: fact %s for unknown or terminal run %s", run.FactType(ev.Fact), ev.RunID) + } + p, err := run.ProtocolFor(out.Schemas[ev.RunID]) + if err != nil { + return m, err + } + proto, state = p, cur + } + next, err := proto.Evolve(state, ev.Fact) + if err != nil { + return m, err + } + if next.Status.Terminal() { + delete(out.Active, ev.RunID) + delete(out.Positions, ev.RunID) + delete(out.Schemas, ev.RunID) + out.Ended[ev.RunID] = struct{}{} + return out, nil + } + out.Active[ev.RunID] = next + out.Positions[ev.RunID] = e.Event.Seq + return out, nil +} + +type machineWire struct { + Runs map[run.RunID]machineRunWire `json:"runs"` + Ended []run.RunID `json:"ended,omitempty"` +} + +type machineRunWire struct { + Schema uint16 `json:"schema"` + Position run.RunPosition `json:"position"` + State jsonstable.Value `json:"state"` +} + +type machineCodec struct{} + +func (machineCodec) Validate(value any) error { + if _, ok := value.(Machine); !ok { + return fmt.Errorf("state is %T, want runmod.Machine", value) + } + return nil +} + +func (c machineCodec) Encode(value any) (jsonstable.Value, error) { + if err := c.Validate(value); err != nil { + return jsonstable.Value{}, err + } + m := value.(Machine) + wire := machineWire{Runs: make(map[run.RunID]machineRunWire, len(m.Active))} + for id, state := range m.Active { + proto, err := run.ProtocolFor(m.Schemas[id]) + if err != nil { + return jsonstable.Value{}, err + } + raw, err := proto.EncodeMachineState(&state) + if err != nil { + return jsonstable.Value{}, err + } + encoded, err := jsonstable.Parse(raw) + if err != nil { + return jsonstable.Value{}, err + } + wire.Runs[id] = machineRunWire{Schema: m.Schemas[id], Position: m.Positions[id], State: encoded} + } + for id := range m.Ended { + wire.Ended = append(wire.Ended, id) + } + sort.Slice(wire.Ended, func(i, j int) bool { return wire.Ended[i] < wire.Ended[j] }) + return jsonstable.FromValue(wire) +} + +func (machineCodec) Decode(wire jsonstable.Value) (any, error) { + if wire.IsZero() { + return nil, errors.New("empty machine snapshot") + } + var w machineWire + if err := json.Unmarshal(wire.Bytes(), &w); err != nil { + return nil, err + } + m := newMachine() + for id, r := range w.Runs { + proto, err := run.ProtocolFor(r.Schema) + if err != nil { + return nil, err + } + state, err := proto.DecodeMachineState(r.State.Bytes()) + if err != nil { + return nil, err + } + m.Active[id] = state + m.Positions[id] = r.Position + m.Schemas[id] = r.Schema + } + for _, id := range w.Ended { + m.Ended[id] = struct{}{} + } + return m, nil +} + +// MachineProjection consumes every twilight/run/ event. +var MachineProjection = extension.ProjectionDefinition{ + ID: MachineProjectionID, Version: 1, + Consumes: AllTypes(), + Initial: func() (any, error) { return newMachine(), nil }, + Apply: func(state any, e extension.DecodedEvent) (any, error) { + return state.(Machine).Apply(e) + }, + StateCodec: machineCodec{}, +} + +var _ session.EventType = Prefix diff --git a/agent/session/run/runtime.go b/agent/session/run/runtime.go new file mode 100644 index 0000000..84130fd --- /dev/null +++ b/agent/session/run/runtime.go @@ -0,0 +1,476 @@ +package runmod + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/extension" +) + +// SourceDigestCarrier is implemented by companion event values whose content +// a Run fact names by digest (TRN-MAP-3). The Runtime verifies every carried +// digest was recorded by a fact of the same group (RUN-CMT-3 step 9). +type SourceDigestCarrier interface { + SourceDigest() es.Digest +} + +// SnapshotPolicy decides whether the machine projection is written to the +// projection cache after a commit. It sees the state before and after Evolve. +type SnapshotPolicy func(before, after *run.MachineState) bool + +// DefaultSnapshotPolicy writes when the Run returns to Open or terminates. +func DefaultSnapshotPolicy(_, after *run.MachineState) bool { + if after.Status.Terminal() { + return true + } + _, open := after.Current.(run.Open) + return open +} + +// Config assembles a Runtime (agent-reference-assembly.md 5). +type Config struct { + Writers extension.Writers + Registry *extension.Registry + Store session.Store // read side for Record and the terminal-Run fallback + Frozen run.FrozenValueStore + Companion run.Companion + Snapshot SnapshotPolicy + // Cache receives the machine projection per SnapshotPolicy; nil disables. + Cache extension.ProjectionCache + Now func() time.Time +} + +// Runtime is the run.Runtime over a Session Writer (RUN-CMT-1). +type Runtime struct { + cfg Config +} + +func NewRuntime(cfg Config) (*Runtime, error) { + switch { + case cfg.Writers == nil, cfg.Registry == nil, cfg.Store == nil: + return nil, errors.New("runmod: runtime requires writers, registry and store") + case cfg.Companion == nil: + return nil, errors.New("runmod: runtime requires a Companion") + } + if cfg.Frozen == nil { + cfg.Frozen = run.NewMemoryFrozenValues() + } + if cfg.Snapshot == nil { + cfg.Snapshot = DefaultSnapshotPolicy + } + if cfg.Now == nil { + cfg.Now = time.Now + } + return &Runtime{cfg: cfg}, nil +} + +// NewMemoryFrozenValues is the in-process FrozenValueStore. +func NewMemoryFrozenValues() *run.MemoryFrozenValues { return run.NewMemoryFrozenValues() } + +func (r *Runtime) nowMilli() int64 { return r.cfg.Now().UnixMilli() } + +func (r *Runtime) writer(ctx context.Context, sid session.SessionID) (extension.Writer, error) { + w, err := r.cfg.Writers.Writer(ctx, sid) + if err != nil { + return nil, ownershipError(err) + } + return w, nil +} + +// ownershipError maps the Writer's ownership loss onto the Run sentinel. +func ownershipError(err error) error { + if errors.Is(err, &extension.Error{Code: extension.ErrOwnershipLost}) || session.IsCode(err, session.ErrOwnershipLost) { + return fmt.Errorf("%w: %v", run.ErrOwnershipLost, err) + } + return err +} + +// --- Load / Record -------------------------------------------------------------- + +func (r *Runtime) Load(ctx context.Context, sid session.SessionID, runID run.RunID) (run.RuntimeSnapshot, error) { + if err := run.CheckContext(ctx); err != nil { + return run.RuntimeSnapshot{}, err + } + w, err := r.writer(ctx, sid) + if err != nil { + return run.RuntimeSnapshot{}, err + } + state, head, err := w.Projections().Load(ctx, sid, MachineProjectionID, MachineProjection.Version) + if err != nil { + return run.RuntimeSnapshot{}, err + } + m := state.(Machine) + if ms, ok := m.Active[runID]; ok { + return run.RuntimeSnapshot{State: ms, Position: m.Positions[runID], Head: head, SchemaVersion: m.Schemas[runID]}, nil + } + // Not active: terminal or unknown. Terminal Runs leave the projection, so + // fold the Run's own events to answer (RUN-CMT-1). + record, err := r.record(ctx, sid, runID, nil) + if err != nil { + return run.RuntimeSnapshot{}, err + } + return record.Snapshot, nil +} + +func (r *Runtime) Record(ctx context.Context, sid session.SessionID, runID run.RunID) (run.RunRecord, error) { + if err := run.CheckContext(ctx); err != nil { + return run.RunRecord{}, err + } + w, err := r.writer(ctx, sid) + if err != nil { + return run.RunRecord{}, err + } + state, _, err := w.Projections().Load(ctx, sid, MachineProjectionID, MachineProjection.Version) + if err != nil { + return run.RunRecord{}, err + } + m := state.(Machine) + var expect *run.MachineState + if ms, ok := m.Active[runID]; ok { + expect = &ms + } + return r.record(ctx, sid, runID, expect) +} + +// record reads the Run's events from the Store, folds them and (when expect +// is given) compares the fold with the projection state. +func (r *Runtime) record(ctx context.Context, sid session.SessionID, runID run.RunID, expect *run.MachineState) (run.RunRecord, error) { + page, err := r.cfg.Store.Read(ctx, session.ReadRequest{SessionID: sid, Types: []session.EventType{Prefix}}) + if err != nil { + return run.RunRecord{}, err + } + var record run.RunRecord + var position run.RunPosition + for i := range page.Events { + e := &page.Events[i] + decoded, err := r.cfg.Registry.Decode(*e) + if err != nil { + return run.RunRecord{}, err + } + if decoded.Unknown { + return run.RunRecord{}, fmt.Errorf("runmod: record: unknown run event %s v%d", e.Type, decoded.Version) + } + ev := decoded.Value.(Event) + if ev.RunID != runID { + continue + } + if len(record.Events) == 0 { + record.Created = e.Seq + } + record.Events = append(record.Events, *e) + record.Facts = append(record.Facts, ev.Fact) + position = e.Seq + } + if len(record.Facts) == 0 { + return run.RunRecord{}, run.ErrRunNotFound + } + state, err := run.FoldRun(record.Facts) + if err != nil { + return run.RunRecord{}, fmt.Errorf("runmod: record: %w", err) + } + if expect != nil && !run.StatesEquivalent(&state, expect) { + return run.RunRecord{}, errors.New("runmod: record: projection diverges from the event fold") + } + created := record.Facts[0].(run.RunCreated) + record.Snapshot = run.RuntimeSnapshot{State: state, Position: position, Head: page.Head, SchemaVersion: created.SchemaVersion} + return record, nil +} + +// --- Commit ------------------------------------------------------------------------ + +func (r *Runtime) Commit(ctx context.Context, sid session.SessionID, req run.CommitRequest) (run.CommitResult, error) { + if err := run.CheckContext(ctx); err != nil { + return run.CommitResult{}, err + } + env := &req.Command + if env.SessionID != sid { + return run.CommitResult{}, fmt.Errorf("runmod: commit: envelope session %q does not match %q", env.SessionID, sid) + } + for _, a := range req.Attach { + if session.HasTypePrefix(a.Type, []session.EventType{Prefix}) { + return run.CommitResult{}, errors.New("runmod: commit: Attach must not carry twilight/run/ events") + } + } + // The frozen request body must be readable before the fact that names it + // is visible; Put is idempotent and content-addressed (RUN-CMT-3). + if prep, ok := env.Command.(run.PrepareModelRequest); ok { + body, err := run.EncodeFrozenRequest(&prep.Request, prep.RequestDigest) + if err != nil { + return run.CommitResult{}, fmt.Errorf("%w: %w", run.ErrStaleRuntime, err) + } + if err := r.cfg.Frozen.Put(ctx, prep.RequestDigest, body); err != nil { + return run.CommitResult{}, err + } + } + w, err := r.writer(ctx, sid) + if err != nil { + return run.CommitResult{}, err + } + + var out evaluated + var rejection error + var before, after run.MachineState + res, err := w.Commit(ctx, func(view extension.View) (*extension.SemanticGroup, error) { + group, result, reject, err := r.evaluate(ctx, view, sid, &req) + if err != nil { + return nil, err + } + if reject != nil { + rejection = reject + return nil, nil + } + out = result + if group != nil { + before, after = result.before, result.Snapshot.State + } + return group, nil + }) + if err != nil { + return run.CommitResult{}, ownershipError(err) + } + if rejection != nil { + return run.CommitResult{}, rejection + } + switch res.Outcome { + case extension.CommitApplied: + out.Status = run.CommitAccepted + out.Events = res.Events + last := res.Events[len(res.Events)-1] + out.Snapshot.Head = session.Head{Next: last.Seq + 1, Digest: last.Digest} + out.Snapshot.Position = res.Events[out.lastFact].Seq + r.afterCommit(ctx, w, sid, &before, &after) + return out.CommitResult, nil + case extension.CommitNoop: + // evaluate found an exact replay and filled out. + return out.CommitResult, nil + case extension.CommitConflict: + return run.CommitResult{}, run.ErrCommandConflict + default: + return run.CommitResult{}, fmt.Errorf("runmod: commit: %s: %s", res.Outcome, res.Detail) + } +} + +// afterCommit writes the machine projection to the cache when the policy asks +// for it (RUN-CMT-2). Cache failures never affect the commit. +func (r *Runtime) afterCommit(ctx context.Context, w extension.Writer, sid session.SessionID, before, after *run.MachineState) { + if r.cfg.Cache == nil || !r.cfg.Snapshot(before, after) { + return + } + state, head, err := w.Projections().Load(ctx, sid, MachineProjectionID, MachineProjection.Version) + if err != nil { + return + } + _ = extension.SaveProjection(ctx, r.cfg.Cache, r.cfg.Registry, sid, MachineProjectionID, MachineProjection.Version, state, head) +} + +type evaluated struct { + run.CommitResult + before run.MachineState + lastFact int +} + +// evaluate is RUN-CMT-3 inside the Writer. It returns either a group to +// append with the prospective result, a filled result for an exact replay +// (group nil), or a rejection error. +func (r *Runtime) evaluate(ctx context.Context, view extension.View, sid session.SessionID, req *run.CommitRequest) (*extension.SemanticGroup, evaluated, error, error) { + env := &req.Command + commitID := session.CommitID(env.ID) + runID := env.RunID + + // Steps 2-3: replay. Idempotency is the Writer's (SessionID, CommitID) + // index alone (RUN-CMT-5): every Run CommandID is content-derived, so a hit + // is the same command. No Decide runs on replay. + if existing, found := view.LookupCommit(commitID); found { + snapshot, err := r.snapshotIn(ctx, view, sid, runID) + if err != nil { + return nil, evaluated{}, nil, err + } + return nil, evaluated{CommitResult: run.CommitResult{Status: run.CommitAlreadyApplied, Snapshot: snapshot, Events: existing}}, nil, nil + } + + // Step 5: current state from the Writer's projection. + proj, err := loadMachine(view) + if err != nil { + return nil, evaluated{}, nil, err + } + state, active := proj.Active[runID] + if !active { + // Terminal Runs leave the projection; tell terminal from unknown. + if _, ended := proj.Ended[runID]; ended { + return nil, evaluated{}, run.ErrRunTerminal, nil + } + if _, err := r.record(ctx, sid, runID, nil); err != nil { + return nil, evaluated{}, err, nil + } + return nil, evaluated{}, run.ErrRunTerminal, nil + } + schema := proj.Schemas[runID] + if env.SchemaVersion != schema { + return nil, evaluated{}, nil, fmt.Errorf("runmod: commit: command schema %d does not match run schema %d", env.SchemaVersion, schema) + } + proto, err := run.ProtocolFor(schema) + if err != nil { + return nil, evaluated{}, nil, err + } + + decision, err := run.EvaluateCommit(state, proj.Positions[runID], *req, proto) + if err != nil { + return nil, evaluated{}, nil, err + } + switch decision.Kind { + case run.DecisionConflict: + return nil, evaluated{}, run.ErrCommandConflict, nil + case run.DecisionStale: + if decision.Reject != nil && !errors.Is(decision.Reject, run.ErrStaleRuntime) { + return nil, evaluated{}, fmt.Errorf("%w: %w", run.ErrStaleRuntime, decision.Reject), nil + } + return nil, evaluated{}, run.ErrStaleRuntime, nil + case run.DecisionTerminal: + return nil, evaluated{}, run.ErrRunTerminal, nil + } + + // Step 8: facts -> events. + now := r.nowMilli() + group := &extension.SemanticGroup{CommitID: commitID} + recorded := map[es.Digest]struct{}{} + for _, f := range decision.Facts { + group.Events = append(group.Events, extension.TypedEvent{Type: EventType(f), RecordedAtUnixMilli: now, Value: Event{RunID: runID, Fact: f}}) + switch fact := f.(type) { + case run.ModelStepCompleted: + recorded[fact.ResultDigest] = struct{}{} + case run.ToolCallCompleted: + recorded[fact.OutputDigest] = struct{}{} + case run.ToolCallAnswered: + recorded[fact.ResponseDigest] = struct{}{} + } + } + // Step 9: companion, then Attach. + companion, err := r.cfg.Companion.Map(run.CompanionRequest{Session: sid, Owner: state.Owner, RunID: runID, + Command: env.Command, Facts: decision.Facts, State: decision.NewState, RecordedAtUnixMilli: now}) + if err != nil { + return nil, evaluated{}, nil, fmt.Errorf("runmod: companion: %w", err) + } + for _, me := range companion { + if session.HasTypePrefix(me.Type, []session.EventType{Prefix}) { + return nil, evaluated{}, nil, errors.New("runmod: companion must not produce twilight/run/ events") + } + // A carried digest must be one a fact of this group recorded; content + // without a Run-recorded digest (a failed call's tool_result) carries none. + if carrier, ok := me.Value.(SourceDigestCarrier); ok { + if d := carrier.SourceDigest(); d != "" { + if _, recordedHere := recorded[d]; !recordedHere { + return nil, evaluated{}, nil, fmt.Errorf("runmod: companion %s SourceDigest is not recorded by a fact of this group", me.Type) + } + } + } + group.Events = append(group.Events, extension.TypedEvent{Type: me.Type, RecordedAtUnixMilli: now, Value: me.Value}) + } + for _, me := range req.Attach { + group.Events = append(group.Events, extension.TypedEvent{Type: me.Type, RecordedAtUnixMilli: now, Value: me.Value}) + } + // A withdrawn request body ends its useful life; a Recovered step keeps it. + if step, ok := env.Command.(run.WithdrawPreparedStep); ok { + if ms, isModel := state.Current.(run.ModelStep); isModel && ms.RefValue.ID == step.StepID { + if dropper, can := r.cfg.Frozen.(interface{ Delete(run.Digest) }); can { + dropper.Delete(ms.RequestDigest) + } + } + } + result := evaluated{ + CommitResult: run.CommitResult{Snapshot: run.RuntimeSnapshot{State: decision.NewState, SchemaVersion: schema}}, + before: state, + lastFact: len(decision.Facts) - 1, + } + return group, result, nil, nil +} + +func loadMachine(view extension.View) (Machine, error) { + state, err := view.Projection(MachineProjectionID, MachineProjection.Version) + if err != nil { + return Machine{}, err + } + return state.(Machine), nil +} + +// snapshotIn is Load inside the Writer. +func (r *Runtime) snapshotIn(ctx context.Context, view extension.View, sid session.SessionID, runID run.RunID) (run.RuntimeSnapshot, error) { + proj, err := loadMachine(view) + if err != nil { + return run.RuntimeSnapshot{}, err + } + if ms, ok := proj.Active[runID]; ok { + return run.RuntimeSnapshot{State: ms, Position: proj.Positions[runID], Head: view.Head(), SchemaVersion: proj.Schemas[runID]}, nil + } + record, err := r.record(ctx, sid, runID, nil) + if err != nil { + return run.RuntimeSnapshot{}, err + } + return record.Snapshot, nil +} + +// --- frozen request, takeover ------------------------------------------------------- + +func (r *Runtime) FrozenRequest(ctx context.Context, digest run.Digest) (run.ModelRequest, error) { + if err := run.CheckContext(ctx); err != nil { + return run.ModelRequest{}, err + } + if digest == "" { + return run.ModelRequest{}, errors.New("runmod: empty request digest") + } + raw, ok, err := r.cfg.Frozen.Get(ctx, digest) + if err != nil { + return run.ModelRequest{}, err + } + if !ok { + return run.ModelRequest{}, fmt.Errorf("%w: request %s", run.ErrFrozenValueMissing, digest) + } + return run.DecodeFrozenRequest(raw, digest) +} + +// RecoverInterrupted is RUN-CMT-7: every Executing target of the Session gets +// one recovery command under the takeover claim of the current Epoch. +func (r *Runtime) RecoverInterrupted(ctx context.Context, sid session.SessionID) (int, error) { + if err := run.CheckContext(ctx); err != nil { + return 0, err + } + w, err := r.writer(ctx, sid) + if err != nil { + return 0, err + } + state, _, err := w.Projections().Load(ctx, sid, MachineProjectionID, MachineProjection.Version) + if err != nil { + return 0, err + } + claim := run.DeriveTakeoverClaim(sid, w.Epoch()) + n := 0 + for runID, ms := range state.(Machine).Active { + proto, err := run.ProtocolFor(state.(Machine).Schemas[runID]) + if err != nil { + return n, err + } + for _, rec := range run.RecoveryCommands(&ms, claim) { + env, err := proto.BuildEnvelope(sid, runID, rec.ID, rec.Command) + if err != nil { + return n, err + } + res, err := r.Commit(ctx, sid, run.CommitRequest{Command: env}) + if err != nil { + if errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrRunTerminal) || errors.Is(err, run.ErrCommandConflict) { + continue + } + return n, err + } + if res.Status == run.CommitAccepted { + n++ + } + } + } + return n, nil +} + +var _ run.Runtime = (*Runtime)(nil) diff --git a/agent/session/run/runtimetest/conformance.go b/agent/session/run/runtimetest/conformance.go new file mode 100644 index 0000000..333e654 --- /dev/null +++ b/agent/session/run/runtimetest/conformance.go @@ -0,0 +1,578 @@ +package runtimetest + +import ( + "errors" + "strings" + "testing" + + "github.com/felinics/twilight/agent/artifact" + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" + "github.com/felinics/twilight/agent/turn" +) + +// Run executes the RUN-CMP-2 Runtime conformance suite against fixtures made +// by factory. +func Run(t *testing.T, factory Factory) { + t.Helper() + for name, fn := range map[string]func(*testing.T, Factory){ + "Creation": testCreation, + "ReplayAndBase": testReplayAndBase, + "InputQueue": testInputQueue, + "StartAndClaim": testStartAndClaim, + "GroupComposition": testGroupComposition, + "Admission": testAdmission, + "SettlementSnapshot": testSettlementSnapshot, + "PrepareCAS": testPrepareCASIgnoresOtherModules, + "Projection": testProjection, + "Isolation": testIsolation, + "Takeover": testTakeover, + "OwnershipLost": testOwnershipLost, + "FrozenValues": testFrozenValues, + } { + t.Run(name, func(t *testing.T) { fn(t, factory) }) + } +} + +// --- 建立与寻址 ----------------------------------------------------------------------- + +func testCreation(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + snap := h.load("r1") + if snap.State.Owner != "t1" || snap.State.Attempt != 1 || len(snap.State.PendingInputs) != 1 || snap.SchemaVersion != run.SchemaVersion1 { + t.Fatalf("created state = %+v", snap.State) + } + // Position is the Seq of the Run's last row: the start group is submitted + // (0), started (1), delivered (2), created (3), accepted (4). + if snap.Position != h.head().Next-1 || snap.Position != 4 { + t.Fatalf("position = %d, want %d (last row of the start group)", snap.Position, h.head().Next-1) + } + // Unknown RunID. + if _, err := h.rt.Load(h.ctx, sid, "nope"); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("load unknown = %v", err) + } + if _, err := h.rt.Record(h.ctx, sid, "nope"); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("record unknown = %v", err) + } + env, _ := run.ProtocolV1().BuildEnvelope(sid, "nope", run.DeriveInputCommandID("nope", "x"), run.AcceptInput{Input: input("x")}) + if _, err := h.rt.Commit(h.ctx, sid, run.CommitRequest{Command: env}); !errors.Is(err, run.ErrRunNotFound) { + t.Fatalf("commit unknown = %v", err) + } + // Schema disagreement is a hard error, not a retriable rejection. + env, _ = run.ProtocolV1().BuildEnvelope(sid, "r1", run.DeriveInputCommandID("r1", "in-2"), run.AcceptInput{Input: input("in-2")}) + env.SchemaVersion = 2 + _, err := h.rt.Commit(h.ctx, sid, run.CommitRequest{Command: env}) + if err == nil || errors.Is(err, run.ErrStaleRuntime) || errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("schema mismatch = %v, want a non-retriable error", err) + } + // Terminated Run: Load returns the terminal state, Commit is terminal. + h.mustCommit("r1", "cancel-1", 0, run.CancelRun{}) + term := h.load("r1") + if term.State.Status != run.RunStopped || term.State.Result == nil { + t.Fatalf("terminal load = %+v", term.State) + } + rec := h.record("r1") + if !run.StatesEquivalent(&rec.Snapshot.State, &term.State) || rec.Snapshot.Position != term.Position { + t.Fatal("terminal Load and Record disagree") + } + if _, err := h.commit("r1", run.DeriveInputCommandID("r1", "late"), 0, run.AcceptInput{Input: input("late")}); !errors.Is(err, run.ErrRunTerminal) { + t.Fatalf("commit on terminal = %v", err) + } + // A second created for the same RunID is refused by the projection, so the + // Writer rejects the group before it reaches the stream. + group := h.startGroup("t2", "r1", 1) + res, err := h.writer().Commit(h.ctx, func(extension.View) (*extension.SemanticGroup, error) { return &group, nil }) + if err != nil || res.Outcome != extension.CommitInvalid { + t.Fatalf("duplicate created = %+v %v, want invalid", res, err) + } +} + +// --- 重放与 Base ---------------------------------------------------------------------- + +func testReplayAndBase(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + first := h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-2"), 0, run.AcceptInput{Input: input("in-2")}) + if first.Status != run.CommitAccepted { + t.Fatal("first accept not accepted") + } + again := h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-2"), 0, run.AcceptInput{Input: input("in-2")}) + if again.Status != run.CommitAlreadyApplied || len(again.Events) != len(first.Events) || again.Events[0].Digest != first.Events[0].Digest { + t.Fatalf("replay = %+v", again) + } + if h.head().Next != first.Snapshot.Head.Next { + t.Fatal("replay appended rows") + } + // Prepare is a hard CAS on the Run's own position. + snap := h.load("r1") + stale := snap.Position - 1 + cmd, id := h.preparedCommand(run.RuntimeSnapshot{State: snap.State, Position: stale, SchemaVersion: snap.SchemaVersion}, false) + if _, err := h.commit("r1", id, stale, cmd); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("stale prepare = %v", err) + } + cmd, id = h.preparedCommand(snap, false) + prepared := h.mustCommit("r1", id, snap.Position, cmd) + // Non-prepare commands accept a zero or stale Base (call-local rebase). + h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-3"), 0, run.AcceptInput{Input: input("in-3")}) + // Terminal replay: an accepted command replays after termination. + h.mustCommit("r1", "cancel", prepared.Snapshot.Position, run.CancelRun{}) + replay := h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-3"), 0, run.AcceptInput{Input: input("in-3")}) + if replay.Status != run.CommitAlreadyApplied || !replay.Snapshot.State.Status.Terminal() { + t.Fatalf("terminal replay = %+v", replay) + } + if _, err := h.commit("r1", run.DeriveInputCommandID("r1", "in-4"), 0, run.AcceptInput{Input: input("in-4")}); !errors.Is(err, run.ErrRunTerminal) { + t.Fatalf("new command after terminal = %v", err) + } + // Derived-identity families must use their derived CommandID. + if _, err := h.commit("r1", "random", 0, run.AcceptInput{Input: input("in-5")}); !errors.Is(err, run.ErrCommandConflict) && !errors.Is(err, run.ErrRunTerminal) { + t.Fatalf("non-derived id = %v", err) + } +} + +// --- 输入入队 -------------------------------------------------------------------------- + +func testInputQueue(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + // Prepared: the input queues and Next asks to withdraw. + step := h.prepare("r1", false) + h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-2"), 0, run.AcceptInput{Input: input("in-2")}) + snap := h.load("r1") + eff, err := run.Next(snap.State) + if err != nil { + t.Fatal(err) + } + if w, ok := eff.(run.WithdrawPrepared); !ok || w.StepID != step { + t.Fatalf("effect while Prepared with input = %#v", eff) + } + h.mustCommit("r1", run.DeriveWithdrawCommandID("r1", step), snap.Position, run.WithdrawPreparedStep{StepID: step}) + snap = h.load("r1") + if _, open := snap.State.Current.(run.Open); !open || len(snap.State.PendingInputs) != 1 || snap.State.ModelSteps != 0 { + t.Fatalf("after withdraw = %+v", snap.State) + } + cmd, id := h.preparedCommand(snap, false) + if len(cmd.InputIDs) != 1 || cmd.InputIDs[0] != "in-2" { + t.Fatalf("replanned prepare consumes %v", cmd.InputIDs) + } + h.mustCommit("r1", id, snap.Position, cmd) + // Executing: the input queues; a result without calls reopens instead of ending. + claim := h.startModel("r1", cmd.StepID) + h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-3"), 0, run.AcceptInput{Input: input("in-3")}) + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", cmd.StepID, "", claim), 0, run.SubmitModelResult{StepID: cmd.StepID, Result: textResult("a")}) + if res.Snapshot.State.Status != run.RunActive { + t.Fatal("run ended with a pending input") + } + if _, open := res.Snapshot.State.Current.(run.Open); !open || len(res.Snapshot.State.PendingInputs) != 1 { + t.Fatalf("after result with pending input = %+v", res.Snapshot.State) + } + // ToolStep: the input queues as well. + h2 := newHarness(t, factory(t)) + h2.startRun("t1", "r2", input("in-1")) + h2.openToolStep("r2", 1) + res = h2.mustCommit("r2", run.DeriveInputCommandID("r2", "in-9"), 0, run.AcceptInput{Input: input("in-9")}) + if _, ok := res.Snapshot.State.Current.(run.ToolStep); !ok || len(res.Snapshot.State.PendingInputs) != 1 { + t.Fatalf("accept on tool step = %+v", res.Snapshot.State) + } +} + +// --- start 与 claim ------------------------------------------------------------------------ + +func testStartAndClaim(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + step, claim := h.executingModel("r1", false) + // Same-claim replay is AlreadyApplied; another claim finds the target taken. + replay := h.mustCommit("r1", run.DeriveStartCommandID("r1", step, "", claim), 0, run.StartModelExecution{StepID: step, Claim: claim}) + if replay.Status != run.CommitAlreadyApplied { + t.Fatalf("start replay = %+v", replay) + } + other := h.claim() + if _, err := h.commit("r1", run.DeriveStartCommandID("r1", step, "", other), 0, run.StartModelExecution{StepID: step, Claim: other}); !errors.Is(err, run.ErrStaleRuntime) { + t.Fatalf("second claim start = %v", err) + } + // Starts and recoveries without a claim are conflicts. + if _, err := h.commit("r1", run.DeriveStartCommandID("r1", step, "", ""), 0, run.StartModelExecution{StepID: step}); !errors.Is(err, run.ErrCommandConflict) { + t.Fatalf("claimless start = %v", err) + } + // Settlement under the attempt's claim; its replay is AlreadyApplied. + settleID := run.DeriveSettlementCommandID("r1", step, "", claim) + res := h.mustCommit("r1", settleID, 0, run.SubmitModelResult{StepID: step, Result: textResult("done")}) + if !res.Snapshot.State.Status.Terminal() { + t.Fatal("settlement did not end the run") + } + again := h.mustCommit("r1", settleID, 0, run.SubmitModelResult{StepID: step, Result: textResult("done")}) + if again.Status != run.CommitAlreadyApplied { + t.Fatalf("settlement replay = %+v", again) + } + // After settlement the start still replays; a new command is terminal. + replay = h.mustCommit("r1", run.DeriveStartCommandID("r1", step, "", claim), 0, run.StartModelExecution{StepID: step, Claim: claim}) + if replay.Status != run.CommitAlreadyApplied { + t.Fatalf("start replay after settlement = %+v", replay) + } +} + +// --- 组的组成 ---------------------------------------------------------------------------- + +func testGroupComposition(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + step, claim := h.executingModel("r1", true) + result, bindings := h.toolCallResult(step, 1) + before := h.head() + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), 0, + run.SubmitModelResult{StepID: step, Result: result, Calls: bindings}) + if h.head().Next != before.Next+session.Seq(len(res.Events)) { + t.Fatal("one command did not produce exactly one group") + } + for i, e := range res.Events { + if e.CommitID != session.CommitID(run.DeriveSettlementCommandID("r1", step, "", claim)) || int(e.Index) != i || e.Last != (i == len(res.Events)-1) { + t.Fatalf("row %d markers = %+v", i, e) + } + } + types := eventTypes(res.Events) + want := []session.EventType{runmod.Prefix + "model_step_completed", runmod.Prefix + "tool_step_opened", chatlog.TypeAssistant} + if strings.Join(asStrings(types), ",") != strings.Join(asStrings(want), ",") { + t.Fatalf("group events = %v, want %v", types, want) + } + if res.Snapshot.Position != res.Events[1].Seq { + t.Fatalf("position = %d, want the last run row %d", res.Snapshot.Position, res.Events[1].Seq) + } + // The companion's SourceDigest equals the fact's ResultDigest. + var resultDigest es.Digest + for _, f := range h.record("r1").Facts { + if c, ok := f.(run.ModelStepCompleted); ok { + resultDigest = c.ResultDigest + } + } + decoded, err := h.registry.Decode(res.Events[2]) + if err != nil { + t.Fatal(err) + } + if a := decoded.Value.(chatlog.AssistantPayload).Assistant; a.SourceDigest != resultDigest || a.TurnID != "t1" { + t.Fatalf("assistant = %+v, want SourceDigest %s", a, resultDigest) + } + // Attach follows the companion; twilight/run/ events are refused. + ts := res.Snapshot.State.Current.(run.ToolStep) + call := ts.Calls[0].CallID + toolClaim := h.startTool("r1", ts.RefValue.ID, call) + output := run.MustParseCanonicalJSON(`{"ok":true}`) + if _, err := h.commit("r1", run.DeriveSettlementCommandID("r1", ts.RefValue.ID, call, toolClaim), 0, + run.SubmitToolResult{StepID: ts.RefValue.ID, CallID: call, Result: run.ToolExecutionResult{Output: output}}, + run.ModuleEvent{Type: runmod.Prefix + "input_accepted", Value: runmod.Event{RunID: "r1", Fact: run.InputAccepted{Input: input("x")}}}); err == nil { + t.Fatal("Attach with a twilight/run/ event accepted") + } + h.submitInputs(input("in-attach")) + res = h.mustCommit("r1", run.DeriveSettlementCommandID("r1", ts.RefValue.ID, call, toolClaim), 0, + run.SubmitToolResult{StepID: ts.RefValue.ID, CallID: call, Result: run.ToolExecutionResult{Output: output}}, + run.ModuleEvent{Type: chatlog.TypeInputDelivered, Value: chatlog.InputDeliveredPayload{InputID: "in-attach", TurnID: "t1"}}) + types = eventTypes(res.Events) + if len(types) != 3 || types[0] != runmod.Prefix+"tool_call_completed" || types[1] != chatlog.TypeToolResult || types[2] != chatlog.TypeInputDelivered { + t.Fatalf("group events = %v", types) + } + outputDigest, _ := run.ProtocolV1().DigestToolOutput(output) + decoded, _ = h.registry.Decode(res.Events[1]) + if r := decoded.Value.(chatlog.ToolResultPayload).ToolResult; r.SourceDigest != outputDigest || r.Status != chatlog.ToolSuccess { + t.Fatalf("tool_result = %+v", r) + } +} + +func asStrings(types []session.EventType) []string { + out := make([]string, len(types)) + for i, t := range types { + out[i] = string(t) + } + return out +} + +// --- admission ------------------------------------------------------------------------------- + +func testAdmission(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + ref := artifact.Ref{Scheme: "cas", Authority: "local", Key: "k1", Durability: artifact.EventBound, Integrity: &artifact.Integrity{Algorithm: "sha256", Value: "x"}} + binding, err := artifact.NewBinding("b1", ref) + if err != nil { + t.Fatal(err) + } + if _, err := h.bindings.CreateBinding(h.ctx, binding); err != nil { + t.Fatal(err) + } + attach := func(bindingID artifact.BindingID) run.ModuleEvent { + a := chatlog.Assistant{ID: "a-attach", TurnID: "t1", Parts: chatlog.Parts{chatlog.ReferencePart{BindingID: bindingID, Name: "f"}}} + a.Digest, _ = chatlog.DigestAssistant(&a) + return run.ModuleEvent{Type: chatlog.TypeAssistant, Value: chatlog.AssistantPayload{Assistant: a}} + } + before := h.head() + // Unregistered binding: the whole group is refused and nothing is written. + if _, err := h.commit("r1", run.DeriveInputCommandID("r1", "in-2"), 0, run.AcceptInput{Input: input("in-2")}, attach("missing")); err == nil { + t.Fatal("unregistered binding admitted") + } + if h.head() != before { + t.Fatal("refused commit wrote to the stream") + } + if len(h.load("r1").State.PendingInputs) != 1 { + t.Fatal("refused commit changed the Run") + } + // Registered binding: the claim is Active once the group is committed. + res := h.mustCommit("r1", run.DeriveInputCommandID("r1", "in-2"), 0, run.AcceptInput{Input: input("in-2")}, attach("b1")) + claimID := extension.DeriveClaimID(session.ProtocolVersion1, sid, res.Events[0].CommitID, mustSet(t, h, "b1").RefSetDigest) + claim, ok, err := h.ledger.LookupClaim(h.ctx, claimID) + if err != nil || !ok || claim.State != artifact.ClaimActive || claim.Owner != extension.CommitOwner(sid, res.Events[0].CommitID) { + t.Fatalf("claim = %+v ok=%v err=%v", claim, ok, err) + } +} + +func mustSet(t *testing.T, h *harness, ids ...artifact.BindingID) artifact.BindingSet { + t.Helper() + set, err := artifact.SetBuilder{Resolver: h.bindings}.Build(h.ctx, ids) + if err != nil { + t.Fatal(err) + } + return set +} + +// --- 结算返回值 -------------------------------------------------------------------------------- + +func testSettlementSnapshot(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + step, claim := h.executingModel("r1", false) + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), 0, run.SubmitModelResult{StepID: step, Result: textResult("done")}) + if res.Snapshot.State.Status != run.RunCompleted || res.Snapshot.State.Result == nil || res.Snapshot.State.Result.Status != run.RunCompleted { + t.Fatalf("settlement snapshot = %+v", res.Snapshot.State) + } + rec := h.record("r1") + if !run.StatesEquivalent(&rec.Snapshot.State, &res.Snapshot.State) || rec.Snapshot.Position != res.Snapshot.Position { + t.Fatalf("settlement snapshot %+v disagrees with record %+v", res.Snapshot, rec.Snapshot) + } + // The completed turn is settled in the same group (companion). + types := eventTypes(res.Events) + if types[len(types)-1] != turn.TypeCompleted { + t.Fatalf("terminal group events = %v, want turn/completed last", types) + } +} + +// --- Prepare hard CAS 对其他模块不敏感 --------------------------------------------------------------- + +func testPrepareCASIgnoresOtherModules(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + h.startRun("t2", "r2", input("in-b")) + snap := h.load("r1") + cmd, id := h.preparedCommand(snap, false) + // Other modules and another Run write after the planner loaded. + h.submitInputs(input("late")) + h.prepare("r2", false) + after := h.load("r1") + if after.Position != snap.Position { + t.Fatalf("foreign writes moved r1 position %d -> %d", snap.Position, after.Position) + } + if after.Head == snap.Head { + t.Fatal("session head did not move") + } + if _, err := h.commit("r1", id, snap.Position, cmd); err != nil { + t.Fatalf("prepare against a moved session head: %v", err) + } +} + +// --- 投影 ------------------------------------------------------------------------------------------- + +func testProjection(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + cached := func() (session.Head, bool) { + _, through, ok, err := h.cache.Load(h.ctx, sid, runmod.MachineProjectionID, runmod.MachineProjection.Version) + if err != nil { + t.Fatal(err) + } + return through, ok + } + step, claim := h.executingModel("r1", true) + if _, ok := cached(); ok { + t.Fatal("projection cached while the Run is mid-step") + } + result, bindings := h.toolCallResult(step, 1) + opened := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), 0, + run.SubmitModelResult{StepID: step, Result: result, Calls: bindings}) + toolStep := opened.Snapshot.State.Current.(run.ToolStep).RefValue.ID + callID := bindings[0].CallID + toolClaim := h.startTool("r1", toolStep, callID) + res := h.mustCommit("r1", run.DeriveSettlementCommandID("r1", toolStep, callID, toolClaim), 0, + run.SubmitToolResult{StepID: toolStep, CallID: callID, Result: run.ToolExecutionResult{Output: run.MustParseCanonicalJSON(`1`)}}) + if _, open := res.Snapshot.State.Current.(run.Open); !open { + t.Fatalf("after tool settlement current = %T", res.Snapshot.State.Current) + } + if through, ok := cached(); !ok || through != res.Snapshot.Head { + t.Fatalf("cache after return to Open = %+v ok=%v, want head %+v", through, ok, res.Snapshot.Head) + } + // The cached state plus tail equals the Writer's state. + observer := extension.NewProjectionReader(h.store, h.registry, h.cache) + fromCache, _, err := observer.Load(h.ctx, sid, runmod.MachineProjectionID, runmod.MachineProjection.Version) + if err != nil { + t.Fatal(err) + } + m := h.machine() + loaded := h.load("r1") + rec := h.record("r1") + active, ok := m.Active["r1"] + if !ok || !run.StatesEquivalent(&active, &loaded.State) || !run.StatesEquivalent(&active, &rec.Snapshot.State) { + t.Fatal("projection, Load and Record disagree") + } + if fromCache := fromCache.(runmod.Machine).Active["r1"]; !run.StatesEquivalent(&fromCache, &active) { + t.Fatal("observer's cache+tail disagrees with the writer's projection") + } + // Terminal Run leaves Active, stays in Ended; Load and Record still answer. + h.mustCommit("r1", "cancel", 0, run.CancelRun{}) + m = h.machine() + if _, still := m.Active["r1"]; still { + t.Fatal("terminal run still in the projection") + } + if _, ended := m.Ended["r1"]; !ended { + t.Fatal("terminal run not remembered in Ended") + } + if h.load("r1").State.Status != run.RunStopped || h.record("r1").Snapshot.State.Status != run.RunStopped { + t.Fatal("terminal run not readable") + } + // An illegal fact sequence does not fold. + if _, err := run.FoldRun([]run.Fact{run.InputAccepted{Input: input("x")}}); err == nil { + t.Fatal("fold without created succeeded") + } +} + +// --- 隔离 -------------------------------------------------------------------------------------------- + +func testIsolation(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + h.startRun("t2", "r2", input("in-b")) + p1 := h.load("r1").Position + h.prepare("r2", false) + h.submitInputs(input("noise")) + h.mustApply(extension.SemanticGroup{CommitID: "turn-noise", Events: []extension.TypedEvent{{Type: turn.TypeStarted, RecordedAtUnixMilli: 1, + Value: turn.StartedPayload{TurnID: "t9", Profile: turn.ProfileRef{ID: "b", Digest: "sha256:b"}, Companion: turn.CompanionV1Version}}}}) + if h.load("r1").Position != p1 { + t.Fatal("r2, chatlog or turn writes moved r1") + } + for _, f := range h.record("r1").Facts { + if c, ok := f.(run.RunCreated); ok && c.RunID != "r1" { + t.Fatal("record of r1 contains another run") + } + } + if len(h.record("r1").Facts) != 2 || len(h.record("r2").Facts) != 3 { + t.Fatalf("facts r1=%d r2=%d", len(h.record("r1").Facts), len(h.record("r2").Facts)) + } +} + +// --- 接管处置 ----------------------------------------------------------------------------------------- + +func testTakeover(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + h.startRun("t2", "r2", input("in-b")) + // r1: executing model; r2: one executing and one pending tool call. + h.executingModel("r1", false) + requestDigest := h.load("r1").State.Current.(run.ModelStep).RequestDigest + toolStep, ids := h.openToolStep("r2", 2) + h.startTool("r2", toolStep, ids[0]) + + h.takeover() + n, err := h.rt.RecoverInterrupted(h.ctx, sid) + if err != nil || n != 2 { + t.Fatalf("RecoverInterrupted = %d %v, want 2", n, err) + } + r1 := h.load("r1") + ms := r1.State.Current.(run.ModelStep) + if r1.State.Status != run.RunActive || ms.Status != run.ModelPrepared || ms.RequestDigest != requestDigest { + t.Fatalf("model after takeover = %+v", r1.State) + } + if _, err := h.rt.FrozenRequest(h.ctx, requestDigest); err != nil { + t.Fatalf("frozen request after takeover: %v", err) + } + r2 := h.load("r2") + ts := r2.State.Current.(run.ToolStep) + if r2.State.Status != run.RunActive || ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { + t.Fatalf("executing call after takeover = %+v", ts.Calls[0]) + } + if ts.Calls[1].Status != run.ToolPending { + t.Fatalf("pending sibling = %+v, want untouched", ts.Calls[1]) + } + // The Unknown travels with its chatlog tool_result in one group. + rec := h.record("r2") + var unknownSeq session.Seq + for _, e := range rec.Events { + if strings.HasSuffix(string(e.Type), "tool_call_failed") { + unknownSeq = e.Seq + } + } + page, err := h.store.Read(h.ctx, session.ReadRequest{SessionID: sid, From: unknownSeq}) + if err != nil || len(page.Events) < 2 || page.Events[1].Type != chatlog.TypeToolResult || page.Events[1].CommitID != page.Events[0].CommitID { + t.Fatalf("rows after the Unknown = %v %v", eventTypes(page.Events), err) + } + decoded, _ := h.registry.Decode(page.Events[1]) + if tr := decoded.Value.(chatlog.ToolResultPayload).ToolResult; tr.Status != chatlog.ToolUnknown || tr.SourceDigest != "" { + t.Fatalf("tool_result = %+v", tr) + } + // Same owner repeats: idempotent, nothing new. + head := h.head() + if n, err := h.rt.RecoverInterrupted(h.ctx, sid); err != nil || n != 0 || h.head() != head { + t.Fatalf("second RecoverInterrupted = %d %v", n, err) + } + // Another takeover with nothing Executing does nothing. + h.takeover() + if n, err := h.rt.RecoverInterrupted(h.ctx, sid); err != nil || n != 0 { + t.Fatalf("RecoverInterrupted with no executing target = %d %v", n, err) + } +} + +// --- 所有权失效 ---------------------------------------------------------------------------------------- + +func testOwnershipLost(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + step, claim := h.executingModel("r1", false) + old := h.takeover() + head := h.head() + _, err := h.commitWith(old, "r1", run.DeriveSettlementCommandID("r1", step, "", claim), 0, run.SubmitModelResult{StepID: step, Result: textResult("late")}) + if !errors.Is(err, run.ErrOwnershipLost) { + t.Fatalf("old owner commit = %v, want ErrOwnershipLost", err) + } + if h.head() != head { + t.Fatal("fenced commit reached the stream") + } + if _, err := old.Load(h.ctx, sid, "r1"); !errors.Is(err, run.ErrOwnershipLost) { + t.Fatalf("old owner load = %v, want ErrOwnershipLost", err) + } + // The new owner is unaffected. + if h.load("r1").State.Current.(run.ModelStep).Status != run.ModelExecuting { + t.Fatal("new owner's view changed") + } +} + +// --- FrozenValueStore -------------------------------------------------------------------------------- + +func testFrozenValues(t *testing.T, factory Factory) { + h := newHarness(t, factory(t)) + h.startRun("t1", "r1", input("in-1")) + step, claim := h.executingModel("r1", false) + digest := h.load("r1").State.Current.(run.ModelStep).RequestDigest + body, _, err := h.frozen.Get(h.ctx, digest) + if err != nil { + t.Fatal(err) + } + if err := h.frozen.Put(h.ctx, digest, body); err != nil { + t.Fatalf("idempotent put: %v", err) + } + if _, err := h.rt.FrozenRequest(h.ctx, "sha256:unknown"); !errors.Is(err, run.ErrFrozenValueMissing) { + t.Fatalf("unknown digest = %v", err) + } + h.mustCommit("r1", run.DeriveSettlementCommandID("r1", step, "", claim), 0, run.SubmitModelResult{StepID: step, Result: textResult("done")}) + h.frozen.Delete(digest) + if _, err := h.rt.Record(h.ctx, sid, "r1"); err != nil { + t.Fatalf("record after dropping the settled body: %v", err) + } +} diff --git a/agent/session/run/runtimetest/harness.go b/agent/session/run/runtimetest/harness.go new file mode 100644 index 0000000..1de2bef --- /dev/null +++ b/agent/session/run/runtimetest/harness.go @@ -0,0 +1,401 @@ +// Package runtimetest is the RUN-CMP-2 Runtime conformance suite. It takes a +// session.Store factory so the Memory store and every durable adapter run the +// same assertions; it asserts Run semantics only and leaves group atomicity, +// digest chains, ownership fencing and cache equivalence to the kernel and +// Module Framework suites. +package runtimetest + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + "github.com/felinics/twilight/agent/artifact" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" + "github.com/felinics/twilight/agent/turn" + "github.com/felinics/twilight/sdk" +) + +// Fixture is one adapter under test. +type Fixture struct { + Store session.Store +} + +// Factory returns a fresh, empty Fixture for one test. +type Factory func(t testing.TB) Fixture + +const sid session.SessionID = "conformance" + +type clock struct { + mu sync.Mutex + now time.Time +} + +func (c *clock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now } +func (c *clock) Advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +// harness is one owner process over a Store: registry, Writers, Runtime. +type harness struct { + t testing.TB + ctx context.Context + fixture Fixture + store session.Store + registry *extension.Registry + bindings *artifact.MemoryBindingStore + ledger *artifact.MemoryLedger + frozen *run.MemoryFrozenValues + cache *extension.MemoryProjectionCache + clock *clock + writers extension.Writers + rt *runmod.Runtime + seq int +} + +func newHarness(t testing.TB, f Fixture) *harness { + t.Helper() + registry, err := extension.BuildRegistry(session.ProtocolVersion1, chatlog.Module, runmod.Module, turn.Module) + if err != nil { + t.Fatal(err) + } + bindings := artifact.NewMemoryBindingStore() + h := &harness{t: t, ctx: context.Background(), fixture: f, store: f.Store, registry: registry, bindings: bindings, + ledger: artifact.NewMemoryLedger(artifact.SetBuilder{Resolver: bindings}), frozen: run.NewMemoryFrozenValues(), + cache: extension.NewMemoryProjectionCache(), clock: &clock{now: time.Unix(1_000_000, 0)}} + if _, err := f.Store.Create(h.ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: sid}); err != nil { + t.Fatal(err) + } + h.open() + return h +} + +// open starts an owner process: Writers over the shared store and a Runtime. +// Takeover lets it supersede the previous owner process, if any. +func (h *harness) open() { + h.t.Helper() + h.writers = extension.NewWriters(h.store, h.registry, extension.Admission{Bindings: h.bindings, Ledger: h.ledger}, session.OpenOptions{Takeover: true}) + rt, err := runmod.NewRuntime(runmod.Config{Writers: h.writers, Registry: h.registry, Store: h.store, + Frozen: h.frozen, Companion: turn.CompanionV1{}, Cache: h.cache, Now: h.clock.Now}) + if err != nil { + h.fatal(err) + } + h.rt = rt +} + +// takeover opens a new owner process over the same store; the previous +// Runtime stays usable so tests can observe its fencing. +func (h *harness) takeover() *runmod.Runtime { + h.t.Helper() + old := h.rt + h.open() + return old +} + +func (h *harness) fatal(args ...any) { h.t.Helper(); h.t.Fatal(args...) } + +func (h *harness) writer() extension.Writer { + h.t.Helper() + w, err := h.writers.Writer(h.ctx, sid) + if err != nil { + h.fatal(err) + } + return w +} + +func (h *harness) head() session.Head { + h.t.Helper() + page, err := h.store.Read(h.ctx, session.ReadRequest{SessionID: sid, From: ^session.Seq(0) >> 1}) + if err != nil { + h.fatal(err) + } + return page.Head +} + +// mustApply commits a typed group through the Writer and returns its rows. +func (h *harness) mustApply(group extension.SemanticGroup) []session.SessionEvent { + h.t.Helper() + res, err := h.writer().Commit(h.ctx, func(extension.View) (*extension.SemanticGroup, error) { return &group, nil }) + if err != nil { + h.fatal(err) + } + if res.Outcome != extension.CommitApplied { + h.fatal(fmt.Sprintf("append %s: %s %s", group.CommitID, res.Outcome, res.Detail)) + } + return res.Events +} + +func input(id string) run.AgentInput { + return run.AgentInput{ID: run.InputID(id), Payload: run.MustParseCanonicalJSON(fmt.Sprintf(`{"text":%q}`, id))} +} + +// submitInputs writes chatlog input_submitted for each input. +func (h *harness) submitInputs(inputs ...run.AgentInput) { + h.t.Helper() + for _, in := range inputs { + h.seq++ + h.mustApply(extension.SemanticGroup{CommitID: session.CommitID(fmt.Sprintf("submitted/%s/%d", in.ID, h.seq)), Events: []extension.TypedEvent{{ + Type: chatlog.TypeInputSubmitted, RecordedAtUnixMilli: 1, + Value: chatlog.InputSubmittedPayload{InputID: chatlog.InputID(in.ID), Content: in.Payload, SubmittedAtUnixMilli: 1}}}}) + } +} + +// startGroup is TRN-STR-2 without a Coordinator: turn/started, input_delivered*, +// run/created, input_accepted*. Owner is the TurnID. +func (h *harness) startGroup(turnID turn.TurnID, runID run.RunID, attempt uint32, inputs ...run.AgentInput) extension.SemanticGroup { + h.t.Helper() + newRun, err := run.BuildNewRunFor(runID, run.OwnerID(turnID), attempt, "") + if err != nil { + h.fatal(err) + } + facts, err := run.ProtocolV1().BuildCreateGroup(newRun, inputs) + if err != nil { + h.fatal(err) + } + group := extension.SemanticGroup{CommitID: session.CommitID(fmt.Sprintf("start/%s/%d", turnID, attempt))} + ids := make([]chatlog.InputID, len(inputs)) + for i, in := range inputs { + ids[i] = chatlog.InputID(in.ID) + } + if attempt == 1 { + group.Events = append(group.Events, extension.TypedEvent{Type: turn.TypeStarted, RecordedAtUnixMilli: 1, + Value: turn.StartedPayload{TurnID: turnID, InputIDs: ids, Profile: turn.ProfileRef{ID: "b", Digest: "sha256:b"}, + Companion: turn.CompanionV1Version}}) + for _, id := range ids { + group.Events = append(group.Events, extension.TypedEvent{Type: chatlog.TypeInputDelivered, RecordedAtUnixMilli: 1, + Value: chatlog.InputDeliveredPayload{InputID: id, TurnID: chatlog.TurnID(turnID)}}) + } + } + for _, f := range facts { + group.Events = append(group.Events, extension.TypedEvent{Type: runmod.EventType(f), RecordedAtUnixMilli: 1, Value: runmod.Event{RunID: runID, Fact: f}}) + } + return group +} + +// startRun creates a Run under turnID with the given inputs. +func (h *harness) startRun(turnID turn.TurnID, runID run.RunID, inputs ...run.AgentInput) { + h.t.Helper() + h.submitInputs(inputs...) + h.mustApply(h.startGroup(turnID, runID, 1, inputs...)) +} + +func (h *harness) load(runID run.RunID) run.RuntimeSnapshot { + h.t.Helper() + snap, err := h.rt.Load(h.ctx, sid, runID) + if err != nil { + h.fatal(err) + } + return snap +} + +func (h *harness) record(runID run.RunID) run.RunRecord { + h.t.Helper() + rec, err := h.rt.Record(h.ctx, sid, runID) + if err != nil { + h.fatal(err) + } + return rec +} + +func (h *harness) proto(runID run.RunID) run.Protocol { + h.t.Helper() + p, err := h.load(runID).Protocol() + if err != nil { + h.fatal(err) + } + return p +} + +// commit builds the envelope and submits it; attach events follow the companion. +func (h *harness) commit(runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand, attach ...run.ModuleEvent) (run.CommitResult, error) { + h.t.Helper() + return h.commitWith(h.rt, runID, id, base, cmd, attach...) +} + +func (h *harness) commitWith(rt *runmod.Runtime, runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand, attach ...run.ModuleEvent) (run.CommitResult, error) { + h.t.Helper() + env, err := h.proto(runID).BuildEnvelope(sid, runID, id, cmd) + if err != nil { + h.fatal(err) + } + return rt.Commit(h.ctx, sid, run.CommitRequest{Base: base, Command: env, Attach: attach}) +} + +func (h *harness) mustCommit(runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand, attach ...run.ModuleEvent) run.CommitResult { + h.t.Helper() + res, err := h.commit(runID, id, base, cmd, attach...) + if err != nil { + h.fatal(fmt.Sprintf("commit %T: %v", cmd, err)) + } + return res +} + +func (h *harness) claim() run.ExecutionClaim { + h.seq++ + return run.ExecutionClaim(fmt.Sprintf("claim-%d", h.seq)) +} + +// --- run building blocks --------------------------------------------------------- + +var toolDef = sdk.ToolDefinition{Name: "echo", Parameters: json.RawMessage(`{"type":"object"}`)} + +func (h *harness) spec() run.ToolSpec { + h.t.Helper() + frozen, err := run.FreezeToolDefinition(toolDef) + if err != nil { + h.fatal(err) + } + d, err := run.ProtocolV1().DigestToolDefinition(frozen) + if err != nil { + h.fatal(err) + } + return run.ToolSpec{Ref: "echo", Name: "echo", DefinitionDigest: d, Policy: run.DirectExecution} +} + +// preparedCommand builds PrepareModelRequest against snap with the derived ids. +func (h *harness) preparedCommand(snap run.RuntimeSnapshot, withTool bool) (run.PrepareModelRequest, run.CommandID) { + h.t.Helper() + req := sdk.Request{Model: "m-1", Messages: []sdk.Message{sdk.UserMessage("go")}} + var specs []run.ToolSpec + if withTool { + req.Tools = []sdk.ToolDefinition{toolDef} + specs = []run.ToolSpec{h.spec()} + } + frozen, err := run.FreezeModelRequest(req) + if err != nil { + h.fatal(err) + } + proto, _ := snap.Protocol() + reqDigest, err := proto.DigestRequest(frozen) + if err != nil { + h.fatal(err) + } + toolsDigest, err := proto.DigestToolSpecs(specs) + if err != nil { + h.fatal(err) + } + binding, err := proto.DigestModelStepBinding("m-1", reqDigest, toolsDigest) + if err != nil { + h.fatal(err) + } + cmdID := run.DeriveModelRequestCommandID(snap.State.RunID, snap.Position) + ids := make([]run.InputID, len(snap.State.PendingInputs)) + for i, in := range snap.State.PendingInputs { + ids[i] = in.ID + } + return run.PrepareModelRequest{StepID: run.DeriveModelStepID(snap.State.RunID, cmdID, binding), Model: "m-1", Request: frozen, + RequestDigest: reqDigest, InputIDs: ids, Tools: specs, ToolsDigest: toolsDigest}, cmdID +} + +// prepare commits a Prepare at the Run's current position and returns the step. +func (h *harness) prepare(runID run.RunID, withTool bool) run.StepID { + h.t.Helper() + snap := h.load(runID) + cmd, id := h.preparedCommand(snap, withTool) + h.mustCommit(runID, id, snap.Position, cmd) + return cmd.StepID +} + +// startModel commits StartModelExecution with a fresh claim. +func (h *harness) startModel(runID run.RunID, step run.StepID) run.ExecutionClaim { + h.t.Helper() + claim := h.claim() + res := h.mustCommit(runID, run.DeriveStartCommandID(runID, step, "", claim), 0, run.StartModelExecution{StepID: step, Claim: claim}) + if res.Status != run.CommitAccepted { + h.fatal("start was not accepted") + } + return claim +} + +// executingModel drives a fresh Run to Model Executing. +func (h *harness) executingModel(runID run.RunID, withTool bool) (run.StepID, run.ExecutionClaim) { + h.t.Helper() + step := h.prepare(runID, withTool) + return step, h.startModel(runID, step) +} + +func textResult(text string) run.ModelResult { + r, err := run.FreezeModelResult(sdk.ModelResult{Text: text, FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}) + if err != nil { + panic(err) + } + return r +} + +// toolCallResult is a model result issuing n calls of the harness tool. +func (h *harness) toolCallResult(step run.StepID, n int) (run.ModelResult, []run.ToolCallBinding) { + h.t.Helper() + spec := h.spec() + calls := make([]sdk.ToolCall, n) + bindings := make([]run.ToolCallBinding, n) + for i := range calls { + args := run.MustParseCanonicalJSON(fmt.Sprintf(`{"i":%d}`, i)) + calls[i] = sdk.ToolCall{ToolCallID: fmt.Sprintf("c%d", i), ToolName: "echo", Input: args.String()} + callID := run.DeriveCallID(step, i) + bd, err := run.DigestToolCallBinding(callID, spec.DefinitionDigest, spec.Policy, args) + if err != nil { + h.fatal(err) + } + bindings[i] = run.ToolCallBinding{CallID: callID, ProviderCallID: calls[i].ToolCallID, ToolRef: spec.Ref, DefinitionDigest: spec.DefinitionDigest, + BindingDigest: bd, Arguments: args, Policy: spec.Policy} + } + r, err := run.FreezeModelResult(sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 2}, ToolCalls: calls}) + if err != nil { + h.fatal(err) + } + return r, bindings +} + +// openToolStep drives a fresh Run to a ToolStep with n Pending calls. +func (h *harness) openToolStep(runID run.RunID, n int) (run.StepID, []run.CallID) { + h.t.Helper() + step, claim := h.executingModel(runID, true) + result, bindings := h.toolCallResult(step, n) + res := h.mustCommit(runID, run.DeriveSettlementCommandID(runID, step, "", claim), 0, + run.SubmitModelResult{StepID: step, Result: result, Calls: bindings}) + ts, ok := res.Snapshot.State.Current.(run.ToolStep) + if !ok { + h.fatal(fmt.Sprintf("after model result current = %T", res.Snapshot.State.Current)) + } + ids := make([]run.CallID, n) + for i := range bindings { + ids[i] = bindings[i].CallID + } + return ts.RefValue.ID, ids +} + +func (h *harness) startTool(runID run.RunID, step run.StepID, call run.CallID) run.ExecutionClaim { + h.t.Helper() + claim := h.claim() + res := h.mustCommit(runID, run.DeriveStartCommandID(runID, step, call, claim), 0, run.StartToolCall{StepID: step, CallID: call, Claim: claim}) + if res.Status != run.CommitAccepted { + h.fatal("tool start was not accepted") + } + return claim +} + +func (h *harness) machine() runmod.Machine { + h.t.Helper() + state, _, err := h.writer().Projections().Load(h.ctx, sid, runmod.MachineProjectionID, runmod.MachineProjection.Version) + if err != nil { + h.fatal(err) + } + return state.(runmod.Machine) +} + +func eventTypes(rows []session.SessionEvent) []session.EventType { + out := make([]session.EventType, len(rows)) + for i := range rows { + out[i] = rows[i].Type + } + return out +} diff --git a/agent/session/run/runtimetest/memory_test.go b/agent/session/run/runtimetest/memory_test.go new file mode 100644 index 0000000..3f9c04a --- /dev/null +++ b/agent/session/run/runtimetest/memory_test.go @@ -0,0 +1,13 @@ +package runtimetest + +import ( + "testing" + + "github.com/felinics/twilight/agent/session" +) + +func TestMemoryStoreConformance(t *testing.T) { + Run(t, func(testing.TB) Fixture { + return Fixture{Store: session.NewMemoryStore()} + }) +} diff --git a/agent/session/sessiontest/conformance.go b/agent/session/sessiontest/conformance.go new file mode 100644 index 0000000..912fbea --- /dev/null +++ b/agent/session/sessiontest/conformance.go @@ -0,0 +1,280 @@ +// Package sessiontest is the Store-parameterized conformance suite of the +// Session kernel (agent-session.md section 7). Memory and durable adapters run +// the same suite. +package sessiontest + +import ( + "context" + "testing" + + "github.com/felinics/twilight/agent/jsonstable" + "github.com/felinics/twilight/agent/session" +) + +// Fixture is one adapter under test. +type Fixture struct { + Store session.Store +} + +// Factory builds a fresh, empty Store for one subtest. +type Factory func(t *testing.T) Fixture + +// Run executes the suite. +func Run(t *testing.T, factory Factory) { + t.Helper() + t.Run("wire", func(t *testing.T) { testWire(t, factory(t)) }) + t.Run("ownership", func(t *testing.T) { testOwnership(t, factory(t)) }) + t.Run("append", func(t *testing.T) { testAppend(t, factory(t)) }) + t.Run("read", func(t *testing.T) { testRead(t, factory(t)) }) + t.Run("scope", func(t *testing.T) { testScope(t, factory(t)) }) +} + +func create(t *testing.T, store session.Store, sid session.SessionID) session.SessionHeader { + t.Helper() + h, err := store.Create(context.Background(), session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: sid, CreatedAtUnixMilli: 1}) + if err != nil { + t.Fatalf("create: %v", err) + } + return h +} + +func open(t *testing.T, store session.Store, sid session.SessionID, takeover bool) session.Writer { + t.Helper() + w, err := store.Open(context.Background(), sid, session.OpenOptions{Takeover: takeover}) + if err != nil { + t.Fatalf("open: %v", err) + } + return w +} + +func ev(typ, payload string) session.UncommittedEvent { + return session.UncommittedEvent{Type: session.EventType(typ), Payload: jsonstable.MustParse(payload), RecordedAtUnixMilli: 1} +} + +func appendGroup(t *testing.T, w session.Writer, id string, events ...session.UncommittedEvent) []session.SessionEvent { + t.Helper() + rows, err := w.Append(context.Background(), session.Group{CommitID: session.CommitID(id), Events: events}) + if err != nil { + t.Fatalf("append %s: %v", id, err) + } + return rows +} + +// SES-WIR-1/2/3: contiguous Seq, group Index/Last, unique CommitID, canonical +// payload, chain rooted at the header, one version per stream. +func testWire(t *testing.T, f Fixture) { + ctx := context.Background() + h := create(t, f.Store, "s") + if h.ProtocolVersion != session.ProtocolVersion1 || h.HeaderDigest == "" { + t.Fatalf("header = %+v", h) + } + if again, err := f.Store.Create(ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: "s", CreatedAtUnixMilli: 1}); err != nil || again.HeaderDigest != h.HeaderDigest { + t.Fatalf("identical create is not idempotent: %+v %v", again, err) + } + if _, err := f.Store.Create(ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: "s", CreatedAtUnixMilli: 2}); !session.IsCode(err, session.ErrConflict) { + t.Fatalf("different create = %v, want conflict", err) + } + if _, err := f.Store.Create(ctx, session.CreateRequest{ProtocolVersion: 9, SessionID: "v9"}); !session.IsCode(err, session.ErrUnsupportedProfile) { + t.Fatalf("unsupported version = %v", err) + } + w := open(t, f.Store, "s", false) + if head := w.Head(); head.Next != 0 || head.Digest != h.HeaderDigest { + t.Fatalf("empty head = %+v", head) + } + g1 := appendGroup(t, w, "c1", ev("twilight/x/a", `{"a":1}`), ev("twilight/y/b", `{"b":2}`)) + g2 := appendGroup(t, w, "c2", ev("twilight/x/c", `{}`)) + if g1[0].Seq != 0 || g1[1].Seq != 1 || g2[0].Seq != 2 { + t.Fatalf("seq not contiguous: %v %v", g1, g2) + } + if g1[0].Index != 0 || g1[0].Last || g1[1].Index != 1 || !g1[1].Last || !g2[0].Last { + t.Fatalf("group markers wrong: %+v %+v", g1, g2) + } + if g1[0].CommitID != "c1" || g1[1].CommitID != "c1" { + t.Fatal("rows of one append must share CommitID") + } + if _, err := w.Append(ctx, session.Group{CommitID: "c1", Events: []session.UncommittedEvent{ev("twilight/x/a", `{}`)}}); !session.IsCode(err, session.ErrConflict) { + t.Fatalf("duplicate CommitID = %v, want conflict", err) + } + if head := w.Head(); head.Next != 3 || head.Digest != g2[0].Digest { + t.Fatalf("head = %+v", head) + } + // Chain: every digest recomputes from the previous row and the header. + page, err := f.Store.Read(ctx, session.ReadRequest{SessionID: "s"}) + if err != nil || len(page.Events) != 3 { + t.Fatalf("read = %+v %v", page, err) + } + if err := session.ValidateChain(session.ProfileV1(), page.Header, page.Events); err != nil { + t.Fatalf("chain: %v", err) + } + if page.Header.HeaderDigest != h.HeaderDigest || page.Head.Next != 3 { + t.Fatalf("page header/head = %+v", page) + } +} + +// SES-OWN-1/2: second Open is ErrOwned; Close then Open bumps Epoch; an Open +// with Takeover supersedes a live owner; a superseded Writer's Append fails +// without writing. +func testOwnership(t *testing.T, f Fixture) { + ctx := context.Background() + create(t, f.Store, "s") + w1 := open(t, f.Store, "s", false) + if w1.Epoch() != 1 { + t.Fatalf("first epoch = %d", w1.Epoch()) + } + if _, err := f.Store.Open(ctx, "s", session.OpenOptions{}); !session.IsCode(err, session.ErrOwned) { + t.Fatalf("second open = %v, want owned", err) + } + appendGroup(t, w1, "c1", ev("twilight/x/a", `{}`)) + if err := w1.Close(ctx); err != nil { + t.Fatal(err) + } + w2 := open(t, f.Store, "s", false) + if w2.Epoch() != 2 { + t.Fatalf("epoch after reopen = %d, want 2", w2.Epoch()) + } + if _, err := w1.Append(ctx, session.Group{CommitID: "c2", Events: []session.UncommittedEvent{ev("twilight/x/a", `{}`)}}); !session.IsCode(err, session.ErrOwnershipLost) { + t.Fatalf("old writer append = %v, want ownership_lost", err) + } + page, _ := f.Store.Read(ctx, session.ReadRequest{SessionID: "s"}) + if len(page.Events) != 1 { + t.Fatalf("fenced append wrote rows: %d", len(page.Events)) + } + appendGroup(t, w2, "c2", ev("twilight/x/a", `{}`)) + if err := w1.Close(ctx); err != nil { + t.Fatalf("closing a superseded writer must be a no-op: %v", err) + } + if _, err := f.Store.Open(ctx, "s", session.OpenOptions{}); !session.IsCode(err, session.ErrOwned) { + t.Fatal("closing a superseded writer released the current owner") + } + // Takeover supersedes the live owner: the crashed-process recovery path. + w3 := open(t, f.Store, "s", true) + if w3.Epoch() != w2.Epoch()+1 { + t.Fatalf("takeover epoch = %d, want %d", w3.Epoch(), w2.Epoch()+1) + } + if _, err := w2.Append(ctx, session.Group{CommitID: "late", Events: []session.UncommittedEvent{ev("twilight/x/a", `{}`)}}); !session.IsCode(err, session.ErrOwnershipLost) { + t.Fatalf("superseded writer append = %v, want ownership_lost", err) + } + appendGroup(t, w3, "c3", ev("twilight/x/a", `{}`)) + page, _ = f.Store.Read(ctx, session.ReadRequest{SessionID: "s"}) + if len(page.Events) != 3 { + t.Fatalf("stream after takeover = %d rows, want 3", len(page.Events)) + } + // Read never needs ownership (SES-OWN-4): already exercised above while owned. +} + +// SES-APP-1/3: whole-group visibility and the rejection list, none writing. +func testAppend(t *testing.T, f Fixture) { + ctx := context.Background() + create(t, f.Store, "s") + w := open(t, f.Store, "s", false) + rejects := []struct { + name string + g session.Group + code session.ErrorCode + }{ + {"empty group", session.Group{CommitID: "c"}, session.ErrInvalid}, + {"empty commit id", session.Group{Events: []session.UncommittedEvent{ev("twilight/x/a", `{}`)}}, session.ErrInvalid}, + {"empty type", session.Group{CommitID: "c", Events: []session.UncommittedEvent{ev("", `{}`)}}, session.ErrInvalid}, + {"non-object payload", session.Group{CommitID: "c", Events: []session.UncommittedEvent{ev("twilight/x/a", `[1]`)}}, session.ErrInvalid}, + {"empty payload", session.Group{CommitID: "c", Events: []session.UncommittedEvent{{Type: "twilight/x/a"}}}, session.ErrInvalid}, + } + for _, tc := range rejects { + if _, err := w.Append(ctx, tc.g); !session.IsCode(err, tc.code) { + t.Fatalf("%s: err = %v, want %s", tc.name, err, tc.code) + } + } + if head := w.Head(); head.Next != 0 { + t.Fatalf("rejections wrote rows: head %+v", head) + } + rows := appendGroup(t, w, "c1", ev("twilight/x/a", `{"i":0}`), ev("twilight/x/a", `{"i":1}`), ev("twilight/x/a", `{"i":2}`)) + page, _ := f.Store.Read(ctx, session.ReadRequest{SessionID: "s"}) + if len(page.Events) != 3 || page.Events[2].Digest != rows[2].Digest { + t.Fatalf("group not visible as a whole: %+v", page.Events) + } + // SourceSeqs and Ignorable round-trip untouched; the kernel does not + // interpret them. + out := appendGroup(t, w, "c2", session.UncommittedEvent{Type: "twilight/x/b", Payload: jsonstable.MustParse(`{}`), SourceSeqs: []session.Seq{7, 1}, Ignorable: true}) + if len(out[0].SourceSeqs) != 2 || out[0].SourceSeqs[0] != 7 || !out[0].Ignorable { + t.Fatalf("row metadata altered: %+v", out[0]) + } +} + +// SES-REP-1/2: order, From, Limit at group boundaries, filter equivalence, +// tamper detection at Open. +func testRead(t *testing.T, f Fixture) { + ctx := context.Background() + create(t, f.Store, "s") + w := open(t, f.Store, "s", false) + appendGroup(t, w, "c1", ev("twilight/run/a", `{}`), ev("twilight/chat/a", `{}`)) // 0,1 + appendGroup(t, w, "c2", ev("twilight/chat/b", `{}`)) // 2 + appendGroup(t, w, "c3", ev("twilight/run/c", `{}`), ev("twilight/run/d", `{}`), ev("twilight/chat/e", `{}`)) // 3,4,5 + all, err := f.Store.Read(ctx, session.ReadRequest{SessionID: "s"}) + if err != nil || len(all.Events) != 6 || all.HasMore { + t.Fatalf("read all = %d %v %v", len(all.Events), all.HasMore, err) + } + for i, e := range all.Events { + if e.Seq != session.Seq(i) { + t.Fatalf("order broken at %d: %+v", i, e) + } + } + from, _ := f.Store.Read(ctx, session.ReadRequest{SessionID: "s", From: 4}) + if len(from.Events) != 2 || from.Events[0].Seq != 4 { + t.Fatalf("from = %+v", from.Events) + } + beyond, _ := f.Store.Read(ctx, session.ReadRequest{SessionID: "s", From: 99}) + if len(beyond.Events) != 0 || beyond.Head.Next != 6 { + t.Fatalf("beyond head = %+v", beyond) + } + limited, _ := f.Store.Read(ctx, session.ReadRequest{SessionID: "s", Limit: 2}) + if len(limited.Events) != 2 || !limited.HasMore || !limited.Events[1].Last { + t.Fatalf("limit must cut at a group boundary: %+v more=%v", limited.Events, limited.HasMore) + } + tiny, _ := f.Store.Read(ctx, session.ReadRequest{SessionID: "s", Limit: 1}) + if len(tiny.Events) != 2 || !tiny.HasMore { + t.Fatalf("a limit below one group still returns the whole first group: %d more=%v", len(tiny.Events), tiny.HasMore) + } + filtered, _ := f.Store.Read(ctx, session.ReadRequest{SessionID: "s", Types: []session.EventType{"twilight/run/"}}) + var want []session.SessionEvent + for _, e := range all.Events { + if session.HasTypePrefix(e.Type, []session.EventType{"twilight/run/"}) { + want = append(want, e) + } + } + if len(filtered.Events) != len(want) { + t.Fatalf("filtered = %d, want %d", len(filtered.Events), len(want)) + } + for i := range want { + if filtered.Events[i].Digest != want[i].Digest { + t.Fatalf("filtered row %d differs from unfiltered", i) + } + } + if _, err := f.Store.Read(ctx, session.ReadRequest{SessionID: "nope"}); !session.IsCode(err, session.ErrNotFound) { + t.Fatalf("unknown session = %v", err) + } + if tamper, ok := f.Store.(interface { + Tamper(session.SessionID, session.Seq, func(*session.SessionEvent)) + }); ok { + if err := w.Close(ctx); err != nil { + t.Fatal(err) + } + tamper.Tamper("s", 2, func(e *session.SessionEvent) { e.Payload = jsonstable.MustParse(`{"x":1}`) }) + if _, err := f.Store.Open(ctx, "s", session.OpenOptions{}); !session.IsCode(err, session.ErrCorrupt) { + t.Fatalf("open over a tampered stream = %v, want corrupt", err) + } + } +} + +// SES-SCP-3: appendix A is out of v1. +func testScope(t *testing.T, f Fixture) { + ctx := context.Background() + if _, err := f.Store.Open(ctx, "missing", session.OpenOptions{}); !session.IsCode(err, session.ErrNotFound) { + t.Fatalf("open unknown session = %v", err) + } + if _, err := f.Store.Header(ctx, "missing"); !session.IsCode(err, session.ErrNotFound) { + t.Fatalf("header unknown session = %v", err) + } + h := session.SessionHeader{ProtocolVersion: session.ProtocolVersion1, SessionID: "f", ParentFork: &session.ForkPoint{ParentSessionID: "p"}} + if err := session.ProfileV1().ValidateHeader(h); !session.IsCode(err, session.ErrUnsupported) { + t.Fatalf("fork header = %v, want unsupported", err) + } +} diff --git a/agent/session/store.go b/agent/session/store.go new file mode 100644 index 0000000..7a3eadf --- /dev/null +++ b/agent/session/store.go @@ -0,0 +1,80 @@ +package session + +import ( + "context" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/jsonstable" +) + +// CreateRequest establishes a stream. Field-identical repeats are idempotent; +// a different request for the same SessionID is a Conflict. +type CreateRequest struct { + ProtocolVersion uint16 + SessionID SessionID + CreatedAtUnixMilli int64 + CausationID es.CausationID + Metadata jsonstable.Value +} + +// OpenOptions configures writer ownership (SES-OWN-1). While a Writer is +// live, an Open without Takeover fails with ErrOwned; an Open with Takeover +// supersedes it — safety rests on Epoch fencing (SES-OWN-2), and when to take +// over is the caller's policy, above the kernel. +type OpenOptions struct { + Takeover bool +} + +// Writer is the kernel's ownership handle returned by Store.Open. Append +// carries its Epoch; a Writer whose Epoch has been superseded gets +// ErrOwnershipLost and writes nothing (SES-OWN-2). +type Writer interface { + SessionID() SessionID + Epoch() Epoch + Head() Head + // Append persists one group atomically and returns the sealed rows + // (SES-APP-1). It rejects empty groups, duplicate CommitIDs, non-canonical + // or non-object payloads, invalid identities and a stale Epoch (SES-APP-3). + Append(context.Context, Group) ([]SessionEvent, error) + Close(context.Context) error +} + +// ReadRequest reads rows from From (inclusive), optionally filtered by +// EventType prefix and limited to whole groups (SES-REP-1). +type ReadRequest struct { + SessionID SessionID + From Seq + Types []EventType // empty = all; otherwise EventType prefix filter + Limit uint32 // 0 = unlimited; truncation only at a group boundary +} + +// ReadPage is the result of one Read. Head is the stream head at read time; +// HasMore reports whether rows beyond the returned ones matched. +type ReadPage struct { + Header SessionHeader + Events []SessionEvent + Head Head + HasMore bool +} + +// Store is the kernel port (SES 4 to 6). +type Store interface { + Create(context.Context, CreateRequest) (SessionHeader, error) + Header(context.Context, SessionID) (SessionHeader, error) + Open(context.Context, SessionID, OpenOptions) (Writer, error) + Read(context.Context, ReadRequest) (ReadPage, error) +} + +// HasTypePrefix reports whether typ matches one of the prefixes (empty list +// matches everything). +func HasTypePrefix(typ EventType, prefixes []EventType) bool { + if len(prefixes) == 0 { + return true + } + for _, p := range prefixes { + if len(typ) >= len(p) && typ[:len(p)] == p { + return true + } + } + return false +} diff --git a/agent/session/types.go b/agent/session/types.go new file mode 100644 index 0000000..0ded581 --- /dev/null +++ b/agent/session/types.go @@ -0,0 +1,151 @@ +// Package session is the append-only log kernel of a Twilight Session +// (docs/design/agent-session.md). It owns the header, one row per +// event, group-atomic append, Session-level writer ownership with epoch +// fencing, the per-row digest chain and ordered reads. Payloads are opaque +// canonical JSON that Session modules encode and interpret. +package session + +import ( + "fmt" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/jsonstable" +) + +type ( + SessionID string + CommitID string + EventType string + // Seq is the row number inside one stream, contiguous from 0. + Seq uint64 + // Epoch is the writer ownership generation of a stream, from 1. + Epoch uint64 +) + +// ProtocolVersion1 is the current pre-release kernel wire version. It covers +// header fields, row fields, digest preimages and the group completeness rule +// only; payload versions are carried by modules (SES-VER-1). +const ProtocolVersion1 uint16 = 1 + +// SessionHeader is the immutable creation record of a stream. +type SessionHeader struct { + ProtocolVersion uint16 `json:"protocolVersion"` + SessionID SessionID `json:"sessionId"` + CreatedAtUnixMilli int64 `json:"createdAtUnixMilli"` + ParentFork *ForkPoint `json:"parentFork,omitempty"` // v1: always nil (appendix A) + CausationID es.CausationID `json:"causationId,omitempty"` + Metadata jsonstable.Value `json:"metadata,omitempty"` + HeaderDigest es.Digest `json:"headerDigest"` +} + +// ForkPoint is reserved for appendix A; v1 rejects non-nil values. +type ForkPoint struct { + ParentSessionID SessionID `json:"parentSessionId"` + Seq Seq `json:"seq"` + Digest es.Digest `json:"digest"` +} + +// SessionEvent is one committed row (SES-WIR-1). Rows written by one Append +// share CommitID; Index orders them and Last marks the group's end. Digest +// covers every other field plus the previous row's Digest (SES-WIR-2). +type SessionEvent struct { + Seq Seq `json:"seq"` + CommitID CommitID `json:"commitId"` + Index uint16 `json:"index"` + Last bool `json:"last"` + Type EventType `json:"type"` + RecordedAtUnixMilli int64 `json:"recordedAtUnixMilli"` + SourceSeqs []Seq `json:"sourceSeqs,omitempty"` + Ignorable bool `json:"ignorable,omitempty"` + Payload jsonstable.Value `json:"payload"` + Digest es.Digest `json:"digest"` +} + +// UncommittedEvent is what a producer hands to Append. +type UncommittedEvent struct { + Type EventType + RecordedAtUnixMilli int64 + SourceSeqs []Seq + Ignorable bool + Payload jsonstable.Value +} + +// Group is one atomic append: a non-empty event list under one CommitID. +type Group struct { + CommitID CommitID + Events []UncommittedEvent +} + +// Head is the stream position after the last row: the next Seq to assign and +// the last row's Digest. The empty stream head is {0, HeaderDigest}. +type Head struct { + Next Seq `json:"next"` + Digest es.Digest `json:"digest"` +} + +// ErrorCode classifies kernel failures (SES 7). +type ErrorCode string + +const ( + ErrInvalid ErrorCode = "invalid" + ErrNotFound ErrorCode = "not_found" + ErrConflict ErrorCode = "conflict" + ErrCorrupt ErrorCode = "corrupt" + ErrOwned ErrorCode = "owned" + ErrOwnershipLost ErrorCode = "ownership_lost" + ErrUnsupportedProfile ErrorCode = "unsupported_profile" + ErrUnsupported ErrorCode = "unsupported" +) + +// Error is the kernel's discriminable error value. +type Error struct { + Code ErrorCode + Operation string + SessionID SessionID + CommitID CommitID + Detail string +} + +func (e *Error) Error() string { + s := fmt.Sprintf("session: %s: %s", e.Operation, e.Code) + if e.SessionID != "" { + s += fmt.Sprintf(" session=%s", e.SessionID) + } + if e.CommitID != "" { + s += fmt.Sprintf(" commit=%s", e.CommitID) + } + if e.Detail != "" { + s += ": " + e.Detail + } + return s +} + +// Is lets callers match on the code: errors.Is(err, &Error{Code: ErrNotFound}). +func (e *Error) Is(target error) bool { + t, ok := target.(*Error) + if !ok { + return false + } + return t.Code == e.Code && (t.Operation == "" || t.Operation == e.Operation) +} + +func newError(code ErrorCode, op string, sid SessionID, detail string) *Error { + return &Error{Code: code, Operation: op, SessionID: sid, Detail: detail} +} + +// IsCode reports whether err is a kernel Error with the given code. +func IsCode(err error, code ErrorCode) bool { + var e *Error + for err != nil { + if ce, ok := err.(*Error); ok { + e = ce + break + } + u, ok := err.(interface{ Unwrap() error }) + if !ok { + break + } + err = u.Unwrap() + } + return e != nil && e.Code == code +} diff --git a/agent/turn/companion.go b/agent/turn/companion.go new file mode 100644 index 0000000..d546143 --- /dev/null +++ b/agent/turn/companion.go @@ -0,0 +1,143 @@ +package turn + +import ( + "fmt" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session/chatlog" +) + +// CompanionV1Version identifies the v1 mapping (TRN-CMP-2). +const CompanionV1Version CompanionVersion = "twilight/turn/companion/v1" + +// CompanionV1 maps Run facts and the command's frozen content to chatlog and +// turn events of the same commit (TRN-CMP). It interprets Owner as TurnID. +type CompanionV1 struct{} + +func (CompanionV1) Version() string { return string(CompanionV1Version) } + +// AssistantID is TRN-MAP-2. +func AssistantID(turnID TurnID, step run.StepID) chatlog.AssistantID { + return chatlog.AssistantID(digestOf("twilight/chatlog/assistant-id", string(turnID), string(step), string(CompanionV1Version))) +} + +// ToolResultID is TRN-MAP-2. +func ToolResultID(turnID TurnID, call run.CallID) chatlog.ToolResultID { + return chatlog.ToolResultID(digestOf("twilight/chatlog/tool-result-id", string(turnID), string(call), string(CompanionV1Version))) +} + +// Map is a pure function of the request (TRN-CMP-1). +func (CompanionV1) Map(req run.CompanionRequest) ([]run.ModuleEvent, error) { + turnID := TurnID(req.Owner) + if turnID == "" { + return nil, nil + } + var out []run.ModuleEvent + for _, f := range req.Facts { + switch fact := f.(type) { + case run.ModelStepCompleted: + cmd, ok := req.Command.(run.SubmitModelResult) + if !ok { + return nil, fmt.Errorf("turn: companion: ModelStepCompleted from %T", req.Command) + } + a, err := assistantFor(turnID, fact, &cmd.Result, callIDs(req.Facts)) + if err != nil { + return nil, err + } + out = append(out, run.ModuleEvent{Type: chatlog.TypeAssistant, Value: chatlog.AssistantPayload{Assistant: a}}) + case run.ToolCallCompleted: + cmd, ok := req.Command.(run.SubmitToolResult) + if !ok { + return nil, fmt.Errorf("turn: companion: ToolCallCompleted from %T", req.Command) + } + r, err := toolResultFor(turnID, fact.CallID, chatlog.ToolSuccess, cmd.Result.Output.String(), fact.OutputDigest) + if err != nil { + return nil, err + } + out = append(out, run.ModuleEvent{Type: chatlog.TypeToolResult, Value: chatlog.ToolResultPayload{ToolResult: r}}) + case run.ToolCallAnswered: + cmd, ok := req.Command.(run.SubmitToolResponse) + if !ok { + return nil, fmt.Errorf("turn: companion: ToolCallAnswered from %T", req.Command) + } + r, err := toolResultFor(turnID, fact.CallID, chatlog.ToolSuccess, cmd.Payload.String(), fact.ResponseDigest) + if err != nil { + return nil, err + } + out = append(out, run.ModuleEvent{Type: chatlog.TypeToolResult, Value: chatlog.ToolResultPayload{ToolResult: r}}) + case run.ToolCallFailed: + status := chatlog.ToolError + if fact.Outcome == run.ToolOutcomeUnknown || fact.Failure.Class == run.FailureEffectUnknown { + status = chatlog.ToolUnknown + } + text := fact.Failure.Class + if fact.Failure.Message != "" { + text += ": " + fact.Failure.Message + } + r, err := toolResultFor(turnID, fact.CallID, status, text, "") + if err != nil { + return nil, err + } + out = append(out, run.ModuleEvent{Type: chatlog.TypeToolResult, Value: chatlog.ToolResultPayload{ToolResult: r}}) + case run.RunEnded: + if _, completed := fact.End.(run.RunCompletedEnd); completed { + out = append(out, run.ModuleEvent{Type: TypeCompleted, Value: CompletedPayload{TurnID: turnID, RunID: req.RunID}}) + } + } + } + return out, nil +} + +// callIDs collects the derived CallIDs the ToolStepOpened of this commit +// assigned, by position in the model result. +func callIDs(facts []run.Fact) []run.CallID { + for _, f := range facts { + if opened, ok := f.(run.ToolStepOpened); ok { + ids := make([]run.CallID, len(opened.Calls)) + for i, b := range opened.Calls { + ids[i] = b.CallID + } + return ids + } + } + return nil +} + +func assistantFor(turnID TurnID, fact run.ModelStepCompleted, result *run.ModelResult, ids []run.CallID) (chatlog.Assistant, error) { + a := chatlog.Assistant{ID: AssistantID(turnID, fact.StepID), TurnID: chatlog.TurnID(turnID), SourceDigest: fact.ResultDigest} + for _, rp := range result.ReasoningParts { + if rp.Text != "" { + a.Parts = append(a.Parts, chatlog.ReasoningPart{Text: rp.Text}) + } + } + if result.Text != "" { + a.Parts = append(a.Parts, chatlog.TextPart{Text: result.Text}) + } + for i, tc := range result.ToolCalls { + callID := run.DeriveCallID(fact.StepID, i) + if i < len(ids) { + callID = ids[i] + } + a.Parts = append(a.Parts, chatlog.ToolCallPart{CallID: chatlog.CallID(callID), ProviderCallID: tc.ToolCallID, Name: tc.ToolName, Input: tc.Input}) + } + d, err := chatlog.DigestAssistant(&a) + if err != nil { + return chatlog.Assistant{}, err + } + a.Digest = d + return a, nil +} + +func toolResultFor(turnID TurnID, callID run.CallID, status chatlog.ToolResultStatus, text string, source es.Digest) (chatlog.ToolResult, error) { + r := chatlog.ToolResult{ID: ToolResultID(turnID, callID), TurnID: chatlog.TurnID(turnID), CallID: chatlog.CallID(callID), + Status: status, Parts: chatlog.Parts{chatlog.TextPart{Text: text}}, SourceDigest: source} + d, err := chatlog.DigestToolResult(&r) + if err != nil { + return chatlog.ToolResult{}, err + } + r.Digest = d + return r, nil +} + +var _ run.Companion = CompanionV1{} diff --git a/agent/turn/coordinator.go b/agent/turn/coordinator.go new file mode 100644 index 0000000..8816f1c --- /dev/null +++ b/agent/turn/coordinator.go @@ -0,0 +1,463 @@ +package turn + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" +) + +// ErrConflict reports a Turn in a state that does not admit the operation. +var ErrConflict = errors.New("turn: conflict") + +type StartRequest struct { + Ref TurnRef + Inputs []run.AgentInput + Profile ProfileRef + Companion CompanionVersion +} +type DeliverRequest struct { + Ref TurnRef + Inputs []run.AgentInput +} +type RetryRequest struct { + Ref TurnRef + Reason string +} +type StopRequest struct { + Ref TurnRef + Reason string +} +type SettleRequest struct { + Ref TurnRef + FailureClass string +} + +type ResumeDisposition string + +const ( + ResumeWaitingForResponse ResumeDisposition = "waiting_for_response" + ResumeWaitingForRecovery ResumeDisposition = "waiting_for_recovery" + ResumeFinished ResumeDisposition = "finished" +) + +type TurnResponse struct { + Ref TurnRef + RunID run.RunID + Attempt uint32 + Status TurnStatus + Disposition ResumeDisposition + End *run.RunEnd + Waiting []run.ResponseRequest +} + +// Service is the Turn API (TRN 3): protocol commits plus the Status read. +// Driving a Run belongs to the host (REF-DRV): every method returns as soon +// as its commit landed, with the response reflecting the committed state. +type Service interface { + Start(context.Context, StartRequest) (TurnResponse, error) + Deliver(context.Context, DeliverRequest) (TurnResponse, error) + Retry(context.Context, RetryRequest) (TurnResponse, error) + Stop(context.Context, StopRequest) (TurnResponse, error) + Settle(context.Context, SettleRequest) (TurnResponse, error) + Status(context.Context, TurnRef) (TurnResponse, error) +} + +// Coordinator has no hidden state (TRN-SCP-3): every method reads the turn +// surface and the machine projection first. Writes and projection reads go +// through the Session's Writer (TRN-SCP-4, TRN-API-1). It never drives a +// Run: it commits protocol transitions and computes dispositions. +type Coordinator struct { + Writers extension.Writers + Runtime run.Runtime + // Now stamps event times; nil selects time.Now. + Now func() time.Time +} + +func (c *Coordinator) now() int64 { + if c.Now != nil { + return c.Now().UnixMilli() + } + return time.Now().UnixMilli() +} + +func (c *Coordinator) writer(ctx context.Context, sid session.SessionID) (extension.Writer, error) { + w, err := c.Writers.Writer(ctx, sid) + if err != nil { + if errors.Is(err, &extension.Error{Code: extension.ErrOwnershipLost}) { + return nil, fmt.Errorf("%w: %v", run.ErrOwnershipLost, err) + } + return nil, err + } + return w, nil +} + +func (c *Coordinator) surface(ctx context.Context, sid session.SessionID) (TurnSurface, error) { + w, err := c.writer(ctx, sid) + if err != nil { + return TurnSurface{}, err + } + state, _, err := w.Projections().Load(ctx, sid, SurfaceProjectionID, SurfaceProjection.Version) + if err != nil { + return TurnSurface{}, err + } + return state.(TurnSurface), nil +} + +// commit runs fn in the Session Writer and maps the outcome (TRN-STR-3). +func (c *Coordinator) commit(ctx context.Context, sid session.SessionID, op string, fn extension.CommitFn) error { + w, err := c.writer(ctx, sid) + if err != nil { + return err + } + res, err := w.Commit(ctx, fn) + if err != nil { + if errors.Is(err, &extension.Error{Code: extension.ErrOwnershipLost}) { + return fmt.Errorf("%w: %v", run.ErrOwnershipLost, err) + } + return err + } + switch res.Outcome { + case extension.CommitApplied, extension.CommitAlreadyApplied: + return nil + case extension.CommitConflict: + return fmt.Errorf("%w: %s replayed with different content", ErrConflict, op) + default: + return fmt.Errorf("turn: %s: %s: %s", op, res.Outcome, res.Detail) + } +} + +// --- Start ------------------------------------------------------------------------ + +func (c *Coordinator) Start(ctx context.Context, req StartRequest) (TurnResponse, error) { + if req.Ref.SessionID == "" || req.Ref.TurnID == "" || req.Profile.ID == "" || req.Profile.Digest == "" || req.Companion == "" { + return TurnResponse{}, errors.New("turn: start requires ref, profile and companion") + } + inputIDs := make([]chatlog.InputID, len(req.Inputs)) + seen := map[run.InputID]struct{}{} + for i, in := range req.Inputs { + if _, dup := seen[in.ID]; dup || in.ID == "" { + return TurnResponse{}, errors.New("turn: start inputs must have unique non-empty IDs") + } + seen[in.ID] = struct{}{} + inputIDs[i] = chatlog.InputID(in.ID) + } + sid, turnID := req.Ref.SessionID, req.Ref.TurnID + plan := PlanDigest(turnID, req.Profile.Digest, req.Companion, inputIDs) + commitID := session.CommitID(StartOperationDigest(sid, turnID, plan)) + runID := DeriveRunID(sid, turnID, 1) + newRun, err := run.BuildNewRunFor(runID, run.OwnerID(turnID), 1, es.CausationID(commitID)) + if err != nil { + return TurnResponse{}, err + } + facts, err := run.ProtocolV1().BuildCreateGroup(newRun, req.Inputs) + if err != nil { + return TurnResponse{}, err + } + now := c.now() + err = c.commit(ctx, sid, "start", func(view extension.View) (*extension.SemanticGroup, error) { + if _, found := view.LookupCommit(commitID); found { + group := c.startGroup(commitID, turnID, inputIDs, req, facts, now) + return &group, nil // exact replay: the Writer compares fingerprints + } + surface, err := loadSurface(view) + if err != nil { + return nil, err + } + if _, exists := surface.Turns[turnID]; exists { + return nil, fmt.Errorf("%w: turn %s already started", ErrConflict, turnID) + } + if _, active := surface.Active(); active { + return nil, fmt.Errorf("%w: session already has an active turn", ErrConflict) + } + if err := checkSubmitted(view, req.Inputs); err != nil { + return nil, err + } + group := c.startGroup(commitID, turnID, inputIDs, req, facts, now) + return &group, nil + }) + if err != nil { + return TurnResponse{}, err + } + return c.respond(ctx, req.Ref, runID) +} + +func (c *Coordinator) startGroup(commitID session.CommitID, turnID TurnID, inputIDs []chatlog.InputID, req StartRequest, facts []run.Fact, now int64) extension.SemanticGroup { + group := extension.SemanticGroup{CommitID: commitID} + group.Events = append(group.Events, extension.TypedEvent{Type: TypeStarted, RecordedAtUnixMilli: now, + Value: StartedPayload{TurnID: turnID, InputIDs: inputIDs, Profile: req.Profile, Companion: req.Companion}}) + for _, id := range inputIDs { + group.Events = append(group.Events, extension.TypedEvent{Type: chatlog.TypeInputDelivered, RecordedAtUnixMilli: now, + Value: chatlog.InputDeliveredPayload{InputID: id, TurnID: chatlog.TurnID(turnID)}}) + } + runID := DeriveRunID(req.Ref.SessionID, turnID, 1) + for _, f := range facts { + group.Events = append(group.Events, extension.TypedEvent{Type: runmod.EventType(f), RecordedAtUnixMilli: now, Value: runmod.Event{RunID: runID, Fact: f}}) + } + return group +} + +func loadSurface(view extension.View) (TurnSurface, error) { + state, err := view.Projection(SurfaceProjectionID, SurfaceProjection.Version) + if err != nil { + return TurnSurface{}, err + } + return state.(TurnSurface), nil +} + +// checkSubmitted enforces TRN-STR-1 (2): each input is a submitted chatlog +// Input whose Content equals the payload. +func checkSubmitted(view extension.View, inputs []run.AgentInput) error { + if len(inputs) == 0 { + return nil + } + state, err := view.Projection(chatlog.SurfaceProjectionID, chatlog.SurfaceProjection.Version) + if err != nil { + return err + } + surface := state.(chatlog.Surface) + for _, in := range inputs { + view, ok := surface.Inputs[chatlog.InputID(in.ID)] + if !ok || view.Status != chatlog.InputSubmitted { + return fmt.Errorf("%w: input %s is not a submitted input", ErrConflict, in.ID) + } + if !view.Input.Content.Equal(in.Payload) { + return fmt.Errorf("%w: input %s payload differs from its submitted content", ErrConflict, in.ID) + } + } + return nil +} + +// --- Deliver ---------------------------------------------------------------------- + +func (c *Coordinator) Deliver(ctx context.Context, req DeliverRequest) (TurnResponse, error) { + sid := req.Ref.SessionID + surface, err := c.surface(ctx, sid) + if err != nil { + return TurnResponse{}, err + } + view, ok := surface.Turns[req.Ref.TurnID] + if !ok || view.Status != TurnActive { + return TurnResponse{}, fmt.Errorf("%w: turn %s is not active", ErrConflict, req.Ref.TurnID) + } + // AcceptInput is not a hard-CAS command (RUN-CMT-4): no Base is needed, and + // the attempt's SchemaVersion comes from the surface, so Deliver does not + // read the machine projection. + att := view.ActiveAttempt() + if att == nil { + return TurnResponse{}, fmt.Errorf("%w: turn %s has no active attempt", ErrConflict, req.Ref.TurnID) + } + runID := att.RunID + proto, err := run.ProtocolFor(att.SchemaVersion) + if err != nil { + return TurnResponse{}, err + } + for _, in := range req.Inputs { + env, err := proto.BuildEnvelope(sid, runID, run.DeriveInputCommandID(runID, in.ID), run.AcceptInput{Input: in}) + if err != nil { + return TurnResponse{}, err + } + _, err = c.Runtime.Commit(ctx, sid, run.CommitRequest{Command: env, + Attach: []run.ModuleEvent{{Type: chatlog.TypeInputDelivered, Value: chatlog.InputDeliveredPayload{InputID: chatlog.InputID(in.ID), TurnID: chatlog.TurnID(req.Ref.TurnID)}}}}) + if err != nil { + if errors.Is(err, run.ErrRunTerminal) { + // The last step settled first (TRN-DLV-3): the input stays submitted. + return c.respond(ctx, req.Ref, runID) + } + return TurnResponse{}, err + } + } + return c.respond(ctx, req.Ref, runID) +} + +// --- Retry / Stop / Settle ------------------------------------------------------ + +func (c *Coordinator) Retry(ctx context.Context, req RetryRequest) (TurnResponse, error) { + sid, turnID := req.Ref.SessionID, req.Ref.TurnID + var runID run.RunID + now := c.now() + err := c.commit(ctx, sid, "retry", func(v extension.View) (*extension.SemanticGroup, error) { + surface, err := loadSurface(v) + if err != nil { + return nil, err + } + view, ok := surface.Turns[turnID] + if !ok || view.Status != TurnAttemptFailed { + return nil, fmt.Errorf("%w: turn %s is not attempt_failed", ErrConflict, turnID) + } + attempt := uint32(len(view.Attempts)) + 1 + runID = DeriveRunID(sid, turnID, attempt) + commitID := RetryCommitID(sid, turnID, attempt) + newRun, err := run.BuildNewRunFor(runID, run.OwnerID(turnID), attempt, es.CausationID(commitID)) + if err != nil { + return nil, err + } + inputs, err := deliveredInputs(v, view.InputIDs) + if err != nil { + return nil, err + } + facts, err := run.ProtocolV1().BuildCreateGroup(newRun, inputs) + if err != nil { + return nil, err + } + group := &extension.SemanticGroup{CommitID: commitID} + for _, f := range facts { + group.Events = append(group.Events, extension.TypedEvent{Type: runmod.EventType(f), RecordedAtUnixMilli: now, Value: runmod.Event{RunID: runID, Fact: f}}) + } + return group, nil + }) + if err != nil { + return TurnResponse{}, err + } + return c.respond(ctx, req.Ref, runID) +} + +// deliveredInputs rebuilds the AgentInputs of a Turn from the chatlog surface, +// in TurnView.InputIDs order (TRN-RTY-1). +func deliveredInputs(view extension.View, ids []chatlog.InputID) ([]run.AgentInput, error) { + state, err := view.Projection(chatlog.SurfaceProjectionID, chatlog.SurfaceProjection.Version) + if err != nil { + return nil, err + } + surface := state.(chatlog.Surface) + out := make([]run.AgentInput, 0, len(ids)) + for _, id := range ids { + view, ok := surface.Inputs[id] + if !ok { + return nil, fmt.Errorf("turn: retry: delivered input %s missing from chatlog", id) + } + out = append(out, run.AgentInput{ID: run.InputID(id), Payload: view.Input.Content}) + } + return out, nil +} + +func (c *Coordinator) Stop(ctx context.Context, req StopRequest) (TurnResponse, error) { + sid, turnID := req.Ref.SessionID, req.Ref.TurnID + surface, err := c.surface(ctx, sid) + if err != nil { + return TurnResponse{}, err + } + view, ok := surface.Turns[turnID] + if !ok || view.Status != TurnActive { + return TurnResponse{}, fmt.Errorf("%w: turn %s is not active", ErrConflict, turnID) + } + att := view.ActiveAttempt() + if att == nil { + return TurnResponse{}, fmt.Errorf("%w: turn %s has no active attempt", ErrConflict, turnID) + } + runID := att.RunID + proto, err := run.ProtocolFor(att.SchemaVersion) + if err != nil { + return TurnResponse{}, err + } + env, err := proto.BuildEnvelope(sid, runID, CancelCommandID(sid, turnID, runID), run.CancelRun{}) + if err != nil { + return TurnResponse{}, err + } + // CancelRun rebases on the current state; no Base and no machine read. + _, err = c.Runtime.Commit(ctx, sid, run.CommitRequest{Command: env, + Attach: []run.ModuleEvent{{Type: TypeFailed, Value: FailedPayload{TurnID: turnID, RunID: runID, Settlement: SettlementStopped, FailureClass: "cancelled"}}}}) + if err != nil && !errors.Is(err, run.ErrRunTerminal) { + return TurnResponse{}, err + } + return c.respond(ctx, req.Ref, runID) +} + +func (c *Coordinator) Settle(ctx context.Context, req SettleRequest) (TurnResponse, error) { + sid, turnID := req.Ref.SessionID, req.Ref.TurnID + var runID run.RunID + now := c.now() + err := c.commit(ctx, sid, "settle", func(v extension.View) (*extension.SemanticGroup, error) { + surface, err := loadSurface(v) + if err != nil { + return nil, err + } + view, ok := surface.Turns[turnID] + if !ok || view.Status != TurnAttemptFailed { + return nil, fmt.Errorf("%w: turn %s is not attempt_failed", ErrConflict, turnID) + } + runID = view.LastAttempt().RunID + return &extension.SemanticGroup{CommitID: SettleCommitID(sid, turnID, runID), Events: []extension.TypedEvent{{ + Type: TypeFailed, RecordedAtUnixMilli: now, + Value: FailedPayload{TurnID: turnID, RunID: runID, Settlement: SettlementFailed, FailureClass: req.FailureClass}}}}, nil + }) + if err != nil { + return TurnResponse{}, err + } + return c.respond(ctx, req.Ref, runID) +} + +// --- Status ---------------------------------------------------------------------------- + +// Status is the pure read: the Turn's committed state and the disposition of +// its last attempt (TRN-STA-1). Hosts call it after driving to assemble the +// conversational result; the disposition logic has this single source. +func (c *Coordinator) Status(ctx context.Context, ref TurnRef) (TurnResponse, error) { + return c.respond(ctx, ref, "") +} + +// respond reads the projections and fills the disposition (TRN-STA-1). +func (c *Coordinator) respond(ctx context.Context, ref TurnRef, runID run.RunID) (TurnResponse, error) { + surface, err := c.surface(ctx, ref.SessionID) + if err != nil { + return TurnResponse{}, err + } + view, ok := surface.Turns[ref.TurnID] + if !ok { + return TurnResponse{}, fmt.Errorf("%w: unknown turn %s", ErrConflict, ref.TurnID) + } + if runID == "" { + if last := view.LastAttempt(); last != nil { + runID = last.RunID + } + } + return c.responseFor(ctx, ref, &view, runID) +} + +func (c *Coordinator) responseFor(ctx context.Context, ref TurnRef, view *TurnView, runIDs ...run.RunID) (TurnResponse, error) { + resp := TurnResponse{Ref: ref, Status: view.Status} + var att *AttemptView + if len(runIDs) > 0 && runIDs[0] != "" { + for i := range view.Attempts { + if view.Attempts[i].RunID == runIDs[0] { + att = &view.Attempts[i] + } + } + } + if att == nil { + att = view.LastAttempt() + } + if att == nil { + return resp, nil + } + resp.RunID, resp.Attempt, resp.End = att.RunID, att.Attempt, att.Ended() + if att.End != nil { + resp.Disposition = ResumeFinished + return resp, nil + } + snapshot, err := c.Runtime.Load(ctx, ref.SessionID, att.RunID) + if err != nil { + return TurnResponse{}, err + } + switch { + case snapshot.State.Status.Terminal(): + resp.Disposition = ResumeFinished + case run.NeedsRecovery(snapshot.State): + resp.Disposition = ResumeWaitingForRecovery + default: + resp.Waiting = run.WaitingCalls(snapshot.State) + if len(resp.Waiting) > 0 { + resp.Disposition = ResumeWaitingForResponse + } + } + return resp, nil +} + +var _ Service = (*Coordinator)(nil) diff --git a/agent/turn/coordinator_test.go b/agent/turn/coordinator_test.go new file mode 100644 index 0000000..e883bc4 --- /dev/null +++ b/agent/turn/coordinator_test.go @@ -0,0 +1,78 @@ +package turn + +import ( + "context" + "testing" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" +) + +// The Coordinator is pure protocol: Start, Deliver and Status commit and read +// without any driver, registry or Loop in the assembly. The Run stays Open +// until a host drives it (REF-DRV-1). +func TestCoordinatorCommitsWithoutDriver(t *testing.T) { + ctx := context.Background() + const sid session.SessionID = "s-protocol" + registry, err := extension.BuildRegistry(session.ProtocolVersion1, chatlog.Module, runmod.Module, Module) + if err != nil { + t.Fatal(err) + } + store := session.NewMemoryStore() + if _, err := store.Create(ctx, session.CreateRequest{ProtocolVersion: session.ProtocolVersion1, SessionID: sid, CreatedAtUnixMilli: 1}); err != nil { + t.Fatal(err) + } + writers := extension.NewWriters(store, registry, extension.Admission{}, session.OpenOptions{}) + runtime, err := runmod.NewRuntime(runmod.Config{Writers: writers, Registry: registry, Store: store, + Frozen: run.NewMemoryFrozenValues(), Companion: CompanionV1{}}) + if err != nil { + t.Fatal(err) + } + c := &Coordinator{Writers: writers, Runtime: runtime} + + submit := func(id chatlog.InputID) run.AgentInput { + t.Helper() + content := run.MustParseCanonicalJSON(`{"text":"hi"}`) + w, err := writers.Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + if _, err := w.Commit(ctx, func(extension.View) (*extension.SemanticGroup, error) { + return &extension.SemanticGroup{CommitID: session.CommitID("submit/" + string(id)), Events: []extension.TypedEvent{{ + Type: chatlog.TypeInputSubmitted, RecordedAtUnixMilli: 1, + Value: chatlog.InputSubmittedPayload{InputID: id, Content: content, SubmittedAtUnixMilli: 1}, + }}}, nil + }); err != nil { + t.Fatal(err) + } + return run.AgentInput{ID: run.InputID(id), Payload: content} + } + + ref := TurnRef{SessionID: sid, TurnID: "t1"} + profile := ProfileRef{ID: "p1", Digest: "sha256:p1"} + start := StartRequest{Ref: ref, Inputs: []run.AgentInput{submit("in-1")}, Profile: profile, Companion: CompanionV1Version} + resp, err := c.Start(ctx, start) + if err != nil { + t.Fatalf("start: %v", err) + } + if resp.Status != TurnActive || resp.Attempt != 1 || resp.RunID == "" || resp.Disposition != "" { + t.Fatalf("start response = %+v, want active attempt 1 with no disposition", resp) + } + if again, err := c.Start(ctx, start); err != nil || again.RunID != resp.RunID { + t.Fatalf("start replay = %+v %v", again, err) + } + if _, err := c.Deliver(ctx, DeliverRequest{Ref: ref, Inputs: []run.AgentInput{submit("in-2")}}); err != nil { + t.Fatalf("deliver: %v", err) + } + status, err := c.Status(ctx, ref) + if err != nil || status.Status != TurnActive || status.RunID != resp.RunID { + t.Fatalf("status = %+v %v", status, err) + } + snap, err := runtime.Load(ctx, sid, resp.RunID) + if err != nil || snap.State.Status.Terminal() { + t.Fatalf("run advanced without a driver: %+v %v", snap.State.Status, err) + } +} diff --git a/agent/turn/module_test.go b/agent/turn/module_test.go new file mode 100644 index 0000000..7899b19 --- /dev/null +++ b/agent/turn/module_test.go @@ -0,0 +1,37 @@ +package turn + +import ( + "testing" + + "github.com/felinics/twilight/agent/session" +) + +// EXT-COD-1: every registered event type's current codec is canonical +// round-trip stable — Encode, Decode, Encode reproduces the bytes. +func TestEventCodecCanonicalRoundTrip(t *testing.T) { + samples := map[session.EventType]any{ + TypeStarted: StartedPayload{TurnID: "t1", InputIDs: nil, Profile: ProfileRef{ID: "b", Digest: "sha256:b"}, Companion: CompanionV1Version}, + TypeCompleted: CompletedPayload{TurnID: "t1", RunID: "run-1"}, + TypeFailed: FailedPayload{TurnID: "t1", RunID: "run-1", Settlement: SettlementFailed, FailureClass: "provider"}, + TypeSuperseded: SupersededPayload{TurnID: "t1", ReplacementTurnID: "t2"}, + } + for _, def := range Module.Events { + value, ok := samples[def.Type] + if !ok { + t.Fatalf("no sample for %s", def.Type) + } + codec := def.Codecs[def.Current] + first, err := codec.Encode(value) + if err != nil { + t.Fatalf("%s: encode: %v", def.Type, err) + } + back, err := codec.Decode(first) + if err != nil { + t.Fatalf("%s: decode: %v", def.Type, err) + } + again, err := codec.Encode(back) + if err != nil || !again.Equal(first) { + t.Fatalf("%s: round trip changed bytes: %s vs %s (%v)", def.Type, first, again, err) + } + } +} diff --git a/agent/turn/projection.go b/agent/turn/projection.go new file mode 100644 index 0000000..64ab998 --- /dev/null +++ b/agent/turn/projection.go @@ -0,0 +1,221 @@ +package turn + +import ( + "fmt" + + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" +) + +const SurfaceProjectionID extension.ProjectionID = "twilight/turn/surface" + +type TurnStatus string + +const ( + TurnActive TurnStatus = "active" + TurnAttemptFailed TurnStatus = "attempt_failed" + TurnCompleted TurnStatus = "completed" + TurnFailed TurnStatus = "failed" + TurnStopped TurnStatus = "stopped" + TurnSuperseded TurnStatus = "superseded" +) + +type AttemptView struct { + RunID run.RunID `json:"runId"` + Attempt uint32 `json:"attempt"` + // SchemaVersion is created.SchemaVersion: the Coordinator builds command + // envelopes for this attempt from it without reading the machine projection. + SchemaVersion uint16 `json:"schemaVersion"` + // End is the terminal result from twilight/run/ended; nil while active. + End *run.RunEnded `json:"end,omitempty"` +} + +// Ended returns the RunEnd variant, or nil for a non-terminal attempt. +func (a *AttemptView) Ended() *run.RunEnd { + if a == nil || a.End == nil { + return nil + } + end := a.End.End + return &end +} + +type TurnView struct { + TurnID TurnID `json:"turnId"` + Status TurnStatus `json:"status"` + InputIDs []chatlog.InputID `json:"inputIds,omitempty"` + Profile ProfileRef `json:"profile"` + Companion CompanionVersion `json:"companion"` + Attempts []AttemptView `json:"attempts,omitempty"` + ActiveRun run.RunID `json:"activeRun,omitempty"` + ReplacementTurnID TurnID `json:"replacementTurnId,omitempty"` +} + +// LastAttempt returns the most recent attempt, if any. +func (v *TurnView) LastAttempt() *AttemptView { + if len(v.Attempts) == 0 { + return nil + } + return &v.Attempts[len(v.Attempts)-1] +} + +// ActiveAttempt returns the attempt behind ActiveRun. +func (v *TurnView) ActiveAttempt() *AttemptView { + for i := range v.Attempts { + if v.Attempts[i].RunID == v.ActiveRun && v.ActiveRun != "" { + return &v.Attempts[i] + } + } + return nil +} + +type TurnSurface struct { + Order []TurnID `json:"order"` + Turns map[TurnID]TurnView `json:"turns"` + // runOwner maps a RunID to its Turn for run event routing. + RunOwner map[run.RunID]TurnID `json:"runOwner"` +} + +func (s TurnSurface) clone() TurnSurface { + out := TurnSurface{Order: append([]TurnID(nil), s.Order...), Turns: make(map[TurnID]TurnView, len(s.Turns)), RunOwner: make(map[run.RunID]TurnID, len(s.RunOwner))} + for k, v := range s.Turns { + v.InputIDs = append([]chatlog.InputID(nil), v.InputIDs...) + v.Attempts = append([]AttemptView(nil), v.Attempts...) + out.Turns[k] = v + } + for k, v := range s.RunOwner { + out.RunOwner[k] = v + } + return out +} + +// Active returns the single active Turn of the Session, if any (TRN-SCP-2). +func (s *TurnSurface) Active() (TurnView, bool) { + for _, id := range s.Order { + if v := s.Turns[id]; v.Status == TurnActive { + return v, true + } + } + return TurnView{}, false +} + +var SurfaceProjection = extension.ProjectionDefinition{ + ID: SurfaceProjectionID, Version: 1, + Consumes: []session.EventType{TypeStarted, TypeCompleted, TypeFailed, TypeSuperseded, + runmod.Prefix + "run_created", runmod.Prefix + "input_accepted", runmod.Prefix + "run_ended"}, + Initial: func() (any, error) { + return TurnSurface{Turns: map[TurnID]TurnView{}, RunOwner: map[run.RunID]TurnID{}}, nil + }, + Apply: applySurface, + StateCodec: extension.JSONStateCodec[TurnSurface]{}, +} + +func applySurface(state any, e extension.DecodedEvent) (any, error) { + s := state.(TurnSurface).clone() + switch p := e.Value.(type) { + case StartedPayload: + if _, dup := s.Turns[p.TurnID]; dup { + return nil, fmt.Errorf("turn %s started twice", p.TurnID) + } + s.Order = append(s.Order, p.TurnID) + s.Turns[p.TurnID] = TurnView{TurnID: p.TurnID, Status: TurnActive, InputIDs: append([]chatlog.InputID(nil), p.InputIDs...), + Profile: p.Profile, Companion: p.Companion} + case CompletedPayload: + v, err := s.settling(p.TurnID) + if err != nil { + return nil, err + } + v.Status, v.ActiveRun = TurnCompleted, "" + s.Turns[p.TurnID] = v + case FailedPayload: + v, err := s.settling(p.TurnID) + if err != nil { + return nil, err + } + v.ActiveRun = "" + if p.Settlement == SettlementStopped { + v.Status = TurnStopped + } else { + v.Status = TurnFailed + } + s.Turns[p.TurnID] = v + case SupersededPayload: + v, err := s.settling(p.TurnID) + if err != nil { + return nil, err + } + v.Status, v.ActiveRun, v.ReplacementTurnID = TurnSuperseded, "", p.ReplacementTurnID + s.Turns[p.TurnID] = v + case runmod.Event: + return s.applyRun(p) + default: + return nil, fmt.Errorf("turn surface: unexpected %T", e.Value) + } + return s, nil +} + +func (s *TurnSurface) settling(id TurnID) (TurnView, error) { + v, ok := s.Turns[id] + if !ok { + return TurnView{}, fmt.Errorf("turn %s settled before started", id) + } + if v.Status != TurnActive && v.Status != TurnAttemptFailed { + return TurnView{}, fmt.Errorf("turn %s settled twice", id) + } + return v, nil +} + +func (s TurnSurface) applyRun(ev runmod.Event) (any, error) { + switch f := ev.Fact.(type) { + case run.RunCreated: + turnID := TurnID(f.Owner) + v, ok := s.Turns[turnID] + if !ok { + // A Run whose owner is not a Turn of this Session is not ours. + return s, nil + } + if v.ActiveRun != "" { + return nil, fmt.Errorf("turn %s already has active run %s", turnID, v.ActiveRun) + } + v.Attempts = append(v.Attempts, AttemptView{RunID: ev.RunID, Attempt: f.Attempt, SchemaVersion: f.SchemaVersion}) + v.ActiveRun = ev.RunID + v.Status = TurnActive + s.Turns[turnID] = v + s.RunOwner[ev.RunID] = turnID + case run.InputAccepted: + turnID, ok := s.RunOwner[ev.RunID] + if !ok { + return s, nil + } + v := s.Turns[turnID] + id := chatlog.InputID(f.Input.ID) + for _, have := range v.InputIDs { + if have == id { + return s, nil + } + } + v.InputIDs = append(v.InputIDs, id) + s.Turns[turnID] = v + case run.RunEnded: + turnID, ok := s.RunOwner[ev.RunID] + if !ok { + return s, nil + } + v := s.Turns[turnID] + for i := range v.Attempts { + if v.Attempts[i].RunID == ev.RunID { + v.Attempts[i].End = &run.RunEnded{End: f.End} + } + } + v.ActiveRun = "" + if v.Status == TurnActive { + // completed runs are settled by the companion's turn/completed in + // the same commit; anything else waits for Retry or Settle. + v.Status = TurnAttemptFailed + } + s.Turns[turnID] = v + } + return s, nil +} diff --git a/agent/turn/turn.go b/agent/turn/turn.go new file mode 100644 index 0000000..a909450 --- /dev/null +++ b/agent/turn/turn.go @@ -0,0 +1,160 @@ +// Package turn is the first-party Turn module (docs/design/agent-turn.md): +// the logical turn, its Run attempts, mid-turn input delivery, settlement, +// and the companion that turns Run facts into conversation content. +package turn + +import ( + "errors" + "fmt" + + "github.com/felinics/twilight/agent/es" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/chatlog" + "github.com/felinics/twilight/agent/session/extension" + runmod "github.com/felinics/twilight/agent/session/run" +) + +const ModuleID extension.ModuleID = "turn" + +type ( + TurnID string + ProfileID string + CompanionVersion string +) + +type TurnRef struct { + SessionID session.SessionID + TurnID TurnID +} + +type ProfileRef struct { + ID ProfileID `json:"id"` + Digest es.Digest `json:"digest"` +} + +type Settlement string + +const ( + SettlementCompleted Settlement = "completed" + SettlementFailed Settlement = "failed" + SettlementStopped Settlement = "stopped" +) + +const ( + TypeStarted session.EventType = "twilight/turn/started" + TypeCompleted session.EventType = "twilight/turn/completed" + TypeFailed session.EventType = "twilight/turn/failed" + TypeSuperseded session.EventType = "twilight/turn/superseded" +) + +type StartedPayload struct { + TurnID TurnID `json:"turnId"` + InputIDs []chatlog.InputID `json:"inputIds,omitempty"` + Profile ProfileRef `json:"profile"` + Companion CompanionVersion `json:"companion"` +} + +type CompletedPayload struct { + TurnID TurnID `json:"turnId"` + RunID run.RunID `json:"runId"` +} + +type FailedPayload struct { + TurnID TurnID `json:"turnId"` + RunID run.RunID `json:"runId"` + Settlement Settlement `json:"settlement"` + FailureClass string `json:"failureClass,omitempty"` +} + +type SupersededPayload struct { + TurnID TurnID `json:"turnId"` + ReplacementTurnID TurnID `json:"replacementTurnId"` +} + +// --- identity derivations (TRN-ID) ---------------------------------------------- + +func digestOf(domain string, parts ...string) es.Digest { + raw, _ := es.EncodeTypedPayload(1, domain, parts) + return es.DigestBytes(raw) +} + +// PlanDigest is TRN-ID-2. +func PlanDigest(turnID TurnID, profile es.Digest, companion CompanionVersion, inputs []chatlog.InputID) es.Digest { + parts := []string{string(turnID), string(profile), string(companion)} + for _, id := range inputs { + parts = append(parts, string(id)) + } + return digestOf("twilight/turn/plan", parts...) +} + +// StartOperationDigest is TRN-ID-3; it is the Start commit's CommitID. +func StartOperationDigest(sid session.SessionID, turnID TurnID, plan es.Digest) es.Digest { + return digestOf("twilight/turn/start-operation", string(sid), string(turnID), string(plan)) +} + +// DeriveRunID is TRN-ID-4. +func DeriveRunID(sid session.SessionID, turnID TurnID, attempt uint32) run.RunID { + return run.RunID(digestOf("twilight/turn/run", string(sid), string(turnID), fmt.Sprintf("%d", attempt))) +} + +func RetryCommitID(sid session.SessionID, turnID TurnID, attempt uint32) session.CommitID { + return session.CommitID(digestOf("twilight/turn/retry", string(sid), string(turnID), fmt.Sprintf("%d", attempt))) +} + +func CancelCommandID(sid session.SessionID, turnID TurnID, runID run.RunID) run.CommandID { + return run.CommandID(digestOf("twilight/turn/cancel-run", string(sid), string(turnID), string(runID), string(run.ReasonCancelled))) +} + +func SettleCommitID(sid session.SessionID, turnID TurnID, runID run.RunID) session.CommitID { + return session.CommitID(digestOf("twilight/turn/settle", string(sid), string(turnID), string(runID))) +} + +// --- module ----------------------------------------------------------------------- + +func def[T any](typ session.EventType, check func(*T) error) extension.EventDefinition { + return extension.EventDefinition{Type: typ, Current: 1, + Codecs: map[extension.PayloadVersion]extension.PayloadCodec{1: extension.JSONCodec[T]{Check: check}}} +} + +// Module declares the turn events, the surface projection and the Requires of +// TRN-SCP-1: run (created, input_accepted, ended v1) and chatlog (present). +var Module = extension.ModuleDescriptor{ + Source: extension.SourceTwilight, + ID: ModuleID, + Requires: []extension.ModuleRequirement{ + {Source: extension.SourceTwilight, Module: runmod.ModuleID, Events: map[session.EventType][]extension.PayloadVersion{ + runmod.Prefix + "run_created": {1}, + runmod.Prefix + "input_accepted": {1}, + runmod.Prefix + "run_ended": {1}, + }}, + {Source: extension.SourceTwilight, Module: chatlog.ModuleID}, + }, + Events: []extension.EventDefinition{ + def[StartedPayload](TypeStarted, func(p *StartedPayload) error { + if p.TurnID == "" || p.Profile.ID == "" || p.Profile.Digest == "" || p.Companion == "" { + return errors.New("started requires turnId, profile and companion") + } + return nil + }), + def[CompletedPayload](TypeCompleted, func(p *CompletedPayload) error { + if p.TurnID == "" || p.RunID == "" { + return errors.New("completed requires turnId and runId") + } + return nil + }), + def[FailedPayload](TypeFailed, func(p *FailedPayload) error { + if p.TurnID == "" || p.RunID == "" || (p.Settlement != SettlementFailed && p.Settlement != SettlementStopped) { + return errors.New("failed requires turnId, runId and settlement failed|stopped") + } + return nil + }), + def[SupersededPayload](TypeSuperseded, func(p *SupersededPayload) error { + if p.TurnID == "" || p.ReplacementTurnID == "" { + return errors.New("superseded requires turnId and replacementTurnId") + } + return nil + }), + }, + Projections: []extension.ProjectionDefinition{SurfaceProjection}, +} diff --git a/cmd/twilight-agent/main.go b/cmd/twilight-agent/main.go new file mode 100644 index 0000000..97a43cb --- /dev/null +++ b/cmd/twilight-agent/main.go @@ -0,0 +1,286 @@ +// Command twilight-agent is a line-oriented CLI agent over the agent core: +// the JSONL file store carries the Session and ref.Session is the host +// object. Each stdin line goes through Session.Send — a line typed while a +// Turn runs steers it (already_driving), a line the running Turn cannot +// accept queues and opens the next Turn after settlement, and a restart over +// the same root takes the Session over and resumes. +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/felinics/twilight/agent/ref" + "github.com/felinics/twilight/agent/run" + "github.com/felinics/twilight/agent/run/loop" + "github.com/felinics/twilight/agent/session" + "github.com/felinics/twilight/agent/session/filestore" + "github.com/felinics/twilight/provider/openai/completions" + "github.com/felinics/twilight/sdk" +) + +func main() { + var ( + root = flag.String("root", "./.twilight", "session store root directory (one subdirectory per session)") + sid = flag.String("session", "default", "session id; reopening the same id resumes its history") + provider = flag.String("provider", "openai-completions", "model provider (only openai-completions)") + baseURL = flag.String("base-url", "", "provider base URL (default: the provider's public endpoint)") + apiKey = flag.String("api-key", "", "provider API key (default: $TWILIGHT_API_KEY)") + modelID = flag.String("model", "", "model id, e.g. gpt-4o or deepseek-chat (required unless -mock)") + compat = flag.String("compat", "", "provider compatibility profile: deepseek") + system = flag.String("system", "", "system prompt") + mock = flag.Bool("mock", false, "offline mode: scripted model plus a built-in `now` tool, no API key") + compactN = flag.Int("compact-after", 0, "auto-compact the context after this many entries (0 disables; /compact always works)") + ) + flag.Parse() + if err := run_(*root, session.SessionID(*sid), *provider, *baseURL, *apiKey, *modelID, *compat, *system, *mock, *compactN); err != nil { + fmt.Fprintln(os.Stderr, "twilight-agent:", err) + os.Exit(1) + } +} + +func run_(root string, sid session.SessionID, provider, baseURL, apiKey, modelID, compat, system string, mock bool, compactAfter int) error { + ctx := context.Background() + agent, err := buildAgent(mock, provider, baseURL, apiKey, modelID, compat, system) + if err != nil { + return err + } + + store, err := filestore.New(root) + if err != nil { + return err + } + // Frozen request bodies persist next to the session log, so a restart can + // replay the request of a ModelStep that was executing at the crash. + frozen, err := filestore.NewFrozenValues(root) + if err != nil { + return err + } + m, err := ref.New(ref.Options{Store: store, Frozen: frozen, Ownership: session.OpenOptions{Takeover: true}, Sink: printSink{}}) + if err != nil { + return err + } + profile, err := m.Agents.Register("cli", agent) + if err != nil { + return err + } + s, err := m.OpenSession(ctx, sid, ref.SessionOptions{Profile: profile, CompactAfterEntries: compactAfter, + CompactWarn: func(err error) { fmt.Fprintln(os.Stderr, "compact:", err) }}) + if err != nil { + return err + } + fmt.Printf("session %s — log at %s\n", sid, store.LogPath(sid)) + if s.Recovered > 0 { + fmt.Printf("takeover: %d executing target disposed\n", s.Recovered) + } + + // Turns run in per-call goroutines; /quit cancels them. A cancelled Turn + // stays Active in the log and the next start resumes it. + driveCtx, cancel := context.WithCancel(ctx) + defer cancel() + var wg sync.WaitGroup + + status, err := s.Status(ctx) + if err != nil { + return err + } + if status.Active != "" { + fmt.Printf("resuming turn %s\n", status.Active) + wg.Add(1) + go func() { + defer wg.Done() + results, _, err := s.Resume(driveCtx) + report(results, err) + }() + } + for _, id := range status.Failed { + fmt.Printf("turn %s failed; /retry to retry it\n", id) + } + + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + switch line := strings.TrimSpace(scanner.Text()); { + case line == "": + case line == "/quit": + return shutdown(ctx, s, cancel, &wg) + case line == "/log": + fmt.Println(store.LogPath(sid)) + case line == "/retry": + wg.Add(1) + go func() { + defer wg.Done() + results, ok, err := s.Retry(driveCtx) + if err == nil && !ok { + fmt.Println("no failed turn to retry") + return + } + report(results, err) + }() + case line == "/compact": + wg.Add(1) + go func() { + defer wg.Done() + id, ok, err := s.Compact(driveCtx) + switch { + case err != nil: + fmt.Fprintln(os.Stderr, "compact:", err) + case !ok: + fmt.Println("nothing to compact") + default: + fmt.Printf("compacted: checkpoint %s\n", id) + } + }() + case strings.HasPrefix(line, "/"): + fmt.Println("commands: /quit /log /retry /compact") + default: + wg.Add(1) + go func(text string) { + defer wg.Done() + results, err := s.Send(driveCtx, text) + report(results, err) + }(line) + } + } + return shutdown(ctx, s, cancel, &wg) +} + +func report(results []ref.Result, err error) { + for _, r := range results { + if r.Disposition == ref.ResumeAlreadyDriving { + fmt.Println("steer: input delivered into the running turn") + continue + } + fmt.Printf("turn %s: %s (%s)\n", r.TurnID, r.Status, r.Disposition) + if r.Reply != "" { + fmt.Println(r.Reply) + } + } + if err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintln(os.Stderr, "error:", err) + } +} + +// shutdown waits for running turns, then cancels the stragglers: a cancelled +// Turn stays Active in the log and the next start resumes it. +func shutdown(ctx context.Context, s *ref.Session, cancel func(), wg *sync.WaitGroup) error { + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + fmt.Fprintln(os.Stderr, "cancelling the running turn; it resumes on the next start") + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + fmt.Fprintln(os.Stderr, "timed out waiting for the running turn") + } + } + return s.Close(ctx) +} + +// printSink surfaces tool activity while a Turn runs (observation only). +type printSink struct{} + +func (printSink) Emit(_ context.Context, e loop.Event) error { + switch e.Kind { + case loop.EventToolStarted: + fmt.Printf("tool call %s started\n", e.CallID) + case loop.EventToolCompleted: + fmt.Printf("tool call %s completed\n", e.CallID) + } + return nil +} + +// --- agent ------------------------------------------------------------------- + +func buildAgent(mock bool, provider, baseURL, apiKey, modelID, compat, system string) (ref.Agent, error) { + if mock { + return ref.NewAgent("mock", mockModel{}, ref.WithTool(nowTool{}), ref.WithSystemPrompt(system)) + } + if provider != "openai-completions" { + return nil, fmt.Errorf("unsupported provider %q (only openai-completions)", provider) + } + if modelID == "" { + return nil, errors.New("-model is required (or use -mock)") + } + if apiKey == "" { + apiKey = os.Getenv("TWILIGHT_API_KEY") + } + if apiKey == "" { + fmt.Fprintln(os.Stderr, "warning: no API key (-api-key or $TWILIGHT_API_KEY)") + } + var opts []completions.Option + if baseURL != "" { + opts = append(opts, completions.WithBaseURL(baseURL)) + } + if apiKey != "" { + opts = append(opts, completions.WithAPIKey(apiKey)) + } + switch compat { + case "": + case "deepseek": + opts = append(opts, completions.WithDeepSeekChatCompletionsCompat()) + default: + return nil, fmt.Errorf("unsupported compat %q (only deepseek)", compat) + } + invoker := providerModel{model: &sdk.Model{ID: modelID, Provider: completions.New(opts...), Type: sdk.ModelTypeChat}} + return ref.NewAgent(run.ModelRef(modelID), invoker, ref.WithSystemPrompt(system)) +} + +type providerModel struct{ model *sdk.Model } + +func (p providerModel) Generate(ctx context.Context, req sdk.Request) (sdk.ModelResult, error) { + return sdk.Generate(ctx, p.model, req) +} + +// mockModel answers once a tool result is in the conversation and reports how +// many messages it saw, so a restart over the same session shows the context +// growing; otherwise it asks for the built-in tool first. A compactor request +// (ref.CompactorSystemPrompt) gets a fixed summary for deterministic smoke. +type mockModel struct{} + +func (mockModel) Generate(_ context.Context, req sdk.Request) (sdk.ModelResult, error) { + if len(req.Messages) > 0 && req.Messages[0].Role == sdk.MessageRoleSystem && messageText(req.Messages[0]) == ref.CompactorSystemPrompt { + return sdk.ModelResult{Text: "mock summary of the compacted conversation", + FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}, nil + } + for _, msg := range req.Messages { + if msg.Role == sdk.MessageRoleTool { + return sdk.ModelResult{Text: fmt.Sprintf("mock: %d messages in context", len(req.Messages)), + FinishReason: sdk.FinishReasonStop, Usage: sdk.Usage{TotalTokens: 1}}, nil + } + } + return sdk.ModelResult{FinishReason: sdk.FinishReasonToolCalls, Usage: sdk.Usage{TotalTokens: 1}, + ToolCalls: []sdk.ToolCall{{ToolCallID: fmt.Sprintf("call-%d", len(req.Messages)), ToolName: "now", Input: `{}`}}}, nil +} + +func messageText(m sdk.Message) string { + var b strings.Builder + for _, part := range m.Content { + if t, ok := part.(sdk.TextPart); ok { + b.WriteString(t.Text) + } + } + return b.String() +} + +type nowTool struct{} + +func (nowTool) Ref() run.ToolRef { return "now" } +func (nowTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{Name: "now", Description: "current UTC time", Parameters: []byte(`{"type":"object","properties":{}}`)} +} +func (nowTool) ResponsePolicy() run.ResponsePolicy { return run.DirectExecution } +func (nowTool) ValidateArguments(run.CanonicalJSON) error { return nil } +func (nowTool) Execute(context.Context, loop.ToolExecutionRequest) loop.ToolExecutionOutcome { + out := run.MustParseCanonicalJSON(fmt.Sprintf(`{"now":%q}`, time.Now().UTC().Format(time.RFC3339))) + return loop.ToolExecutionSucceeded{Result: run.ToolExecutionResult{Output: out}} +} diff --git a/docs/design/agent-artifact.md b/docs/design/agent-artifact.md new file mode 100644 index 0000000..73f35b4 --- /dev/null +++ b/docs/design/agent-artifact.md @@ -0,0 +1,230 @@ +# Twilight Agent Artifact Core + +状态:设计草案。`agent/artifact` 已实现 Ref、Binding、Memory BindingStore、BindingSetBuilder 与自持久化的两态 ledger(`MemoryLedger.Activate` 在 owner fact Append 之前建立 claim,回收前核对由 `OwnerVerifier` 与 `Reconcile` 提供);Resolver、Store、Promoter 与 scheme registry 未实现。wire 与 claim 状态表在 conformance 通过前不冻结;保留子系统(claim、ledger、Reconcile)当前没有真实内容存储与 GC 消费者,其 conformance 随第一个真实内容存储一起冻结,在此之前允许修订。v1 的 claim 只有 `Active` 与 `Released` 两态;`Prepared` 状态、provider 迁移 fence 与 archive import/export 在附录中,不进入 v1 conformance。 + +本文定义 `agent/artifact`。文中的"必须""不得""应该"是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 + +## 1. 模型与范围 + +Artifact Core 只有三个模型: + +```text +Ref:定位并验证不可变内容 +Binding:稳定 BindingID 到 immutable Ref 的映射 +RetentionClaim:owner 对一个 BindingSet 的 durable 保留事实 +``` + +`BindingSet` 是 claim 的内容集合。`Active` claim 是 retention root;`Released` claim 不再保留任何内容。v1 中 claim 由 Session Module Framework 的 `Writer` 在 Append owner fact 之前以 `Active` 状态建立(EXT-WRT-3)。顺序固定为先 claim 后 append,因此不可能出现"stream 引用了内容而没有 claim";可能出现的只有孤儿 claim(有 claim、owner fact 未写入),它只多占空间,由回收前核对释放(ART-RET-3)。`Prepared` 保留给需要显式 in-flight 状态的部署(附录)。Core 不依赖 Session、Event、Chatlog 或 Application,且不解释 owner 的领域语义。Attachment 等 owner module 可以关联 `AttachmentID`、subject 与 `BindingID`,但该边界只使用 BindingID,不引入 Event 依赖。 + +**ART-SCP-1** Core 不得解释 `ClaimOwner`,不得要求某种数据库、文件系统或 provider 实现。v1 只要求 Memory reference implementation 和 conformance suite。 + +**ART-SCP-2** v1 范围:Ref、Binding、Resolver/Store/Promoter capability、两态 RetentionLedger、SchemeDefinition 与 provider binding registry。附录中的能力在 v1 返回 `ErrUnsupported`。 + +## 2. identity 与 wire + +```go +type WireVersion uint16 +type Scheme string +type Authority string +type Key string +type BindingID string +type BindingDigest string +type RefWireIdentity string +type ClaimID string +type RefSetDigest string +type ProviderKindID string +type ProviderInstanceID string + +type Durability string +const ( + Ephemeral Durability = "ephemeral" + EventBound Durability = "event_bound" + Pinned Durability = "pinned" +) + +type Integrity struct { Algorithm, Value string } +type Ref struct { + Scheme Scheme; Authority Authority; Key Key + MediaType string + SizeBytes *uint64 + Integrity *Integrity + Durability Durability + ExpiresAtUnixMilli *int64 +} +``` + +**ART-ID-1** 所有 identity 必须非空、稳定,按 bytewise UTF-8 比较;精确 identity、整数和 digest 在 JSON 中为 string。Ref 不得含 credential、临时签名 URL 或进程 handle。 + +**ART-REF-1** `LocatorIdentity=(Scheme, Authority, Key)`;`RefWireIdentity` 是完整 Ref 的版本化 canonical wire encoding,`RefIdentity(Ref)` 必须由 WireCodec 实现。因此 `MediaType` 是 identity-bound:它进入 RefWireIdentity 和 BindingDigest;但它仍是来自内容声明的 untrusted metadata,resolver、materializer 和安全策略不得仅据它判定可执行性、解析器或权限。相同 locator 的 size(含 presence)和 integrity(含 presence)必须一致,否则 admission 和 resolve 失败。 + +**ART-REF-2** `cas` 必须带 integrity;`ExpiresAtUnixMilli` 仅允许 `Ephemeral`;`EventBound` 和 `Pinned` 不得过期。durability 顺序为 `Ephemeral < EventBound < Pinned`,promotion 只能产生同级或更高的新 Ref。 + +**ART-WIR-1** `WireVersion` 冻结字段、required/omitted、array order、unknown-field policy 和 digest preimage。v1 省略 optional empty field、拒绝 null 和未知 envelope field;`size_bytes`、`expires_at_unix_milli` 使用无前导零十进制 string。codec 必须提供: + +```go +type WireCodec interface { + Version() WireVersion + RefIdentity(Ref) (RefWireIdentity, error) + EncodeRef(Ref) (jsonstable.Value, error) + DecodeRef(jsonstable.Value) (Ref, error) + EncodeBinding(Binding) (jsonstable.Value, error) + DecodeBinding(jsonstable.Value) (Binding, error) + EncodeManifest(BindingManifest) (jsonstable.Value, error) // 附录第 7 节;v1 返回 ErrUnsupported + DecodeManifest(jsonstable.Value) (BindingManifest, error) // 同上 +} +``` + +## 3. Ref 与 Binding + +```go +type Binding struct { ID BindingID; Ref Ref; Digest BindingDigest } +type Info struct { MediaType string; SizeBytes *uint64; Integrity *Integrity; Durability Durability } +type PutRequest struct { MediaType string; Reader io.Reader; Durability Durability } +type PromoteRequest struct { TargetScheme Scheme; TargetAuthority Authority; Durability Durability } + +type Resolver interface { + Stat(context.Context, Ref) (Info, error) + Open(context.Context, Ref) (io.ReadCloser, Info, error) +} +type Store interface { Put(context.Context, PutRequest) (Ref, error) } +type Promoter interface { Promote(context.Context, Ref, PromoteRequest) (Ref, error) } +type BindingResolver interface { ResolveBinding(context.Context, BindingID) (Binding, error) } +type BindingStore interface { + CreateBinding(context.Context, Binding) (Binding, error) + LookupBinding(context.Context, BindingID) (Binding, bool, error) +} +``` + +**ART-BND-1** Binding immutable。`BindingDigest` 覆盖 versioned domain separator、BindingID 和完整 RefWireIdentity。相同 BindingID 只可重建逐字段相同的 Binding;其他值为 conflict。 + +**ART-BND-2** Resolver 必须验证返回 bytes 与声明的 size/integrity 一致。Store 只有在 durable acknowledgement 后返回 Ref;同 immutable identity 和 bytes 的重复 Put 幂等。promotion 流程为 `resolve → promote → CreateBinding(target Ref)`,不得重写旧 Binding。 + +## 4. capability interfaces + +**ART-CAP-1** Resolver、Store、Promoter 是 capability boundary:它们必须区分 `missing`、`expired`、`unauthorized`、`corrupt` 和 transient failure,并防护跨 `Authority` key confusion、path traversal、size amplification 与不安全 media-type trust。 + +**ART-CAP-2** `Scheme` 是 resolution contract;`Authority` 是逻辑 store instance;`Key` 由 scheme 解释。标准 scheme 为 `cas`(content digest)、`spill`(opaque temporary key)和 `workspace`(immutable revision + canonical path)。自定义 scheme 使用 `ext:/`,发布后不得破坏其 key、integrity、durability 或 resolution contract。 + +## 5. retention ledger + +```go +type ClaimOwner struct { Kind, Authority, Identity string } +type ClaimState string +const ( + ClaimActive ClaimState = "active" + ClaimReleased ClaimState = "released" + ClaimPrepared ClaimState = "prepared" // 仅附录的两阶段部署使用 +) + +// BindingSet is a canonical, resolved retention set. +type BindingSet struct { BindingIDs []BindingID; RefSetDigest RefSetDigest } +type BindingSetBuilder interface { + Build(context.Context, []BindingID) (BindingSet, error) +} +type RetentionClaim struct { + ID ClaimID; Owner ClaimOwner; BindingSet BindingSet; State ClaimState +} +type ClaimCursor struct { Watermark ClaimID; After ClaimID } +type ClaimPage struct { Items []RetentionClaim; Next *ClaimCursor } +type ClaimOwnerQuery struct { Kind, Authority string; Identities []string } + +// RetentionLedger 自行持久化(Memory、文件或数据库),不依赖宿主事务。 +type RetentionLedger interface { + // Activate 建立或幂等确认一个 Active claim;返回即持久。 + Activate(context.Context, ClaimID, ClaimOwner, BindingSet) (RetentionClaim, error) + LookupClaim(context.Context, ClaimID) (RetentionClaim, bool, error) + ReleaseActive(context.Context, ClaimID) error + ClaimsByOwner(context.Context, ClaimOwnerQuery, ClaimCursor) (ClaimPage, error) +} +// OwnerVerifier 由 owner 的宿主提供:owner fact 是否已持久存在。 +// Session 部署中 owner 为 {Kind:"twilight/session/commit", Authority:SessionID, Identity:CommitID}, +// 实现为对该 Session 查找该 CommitID 的行。 +type OwnerVerifier interface { + OwnerExists(context.Context, ClaimOwner) (bool, error) +} +``` + +**ART-RET-1** `BindingSetBuilder.Build(ctx, ids)` 是构造 BindingSet 的唯一算法:它将 ids canonicalize 为 sorted-unique `BindingID`,逐个通过 BindingResolver resolve,并计算覆盖 profile、WireVersion 和按 BindingID 排序的 `(BindingID, BindingDigest)` 的 `RefSetDigest`。`BindingSet` 必须同时携带这两个值,不能由调用者单独拼接 digest。ledger 必须以自己的 BindingResolver 重建并精确验证传入 set。 + +**ART-RET-2** claim 只接受 `EventBound` 或 `Pinned` Binding;`Ephemeral` 必须先 promote。ClaimID 必须由 owner fact identity 与 BindingSet 稳定、确定地派生,并永久绑定该 owner 与 set:`Activate` 对同 ID、同 owner、同 set 幂等,对任何其他组合 conflict。`Active` claim 是 GC root;GC 只忽略 `Released` claim。未知 scheme 必须保守保留。 + +| 操作 | 前置状态 | 结果 | +|---|---|---| +| Activate(new ID, owner, set) | 不存在 | Active | +| Activate(existing ID, exact owner/set) | Active | 幂等成功 | +| Activate(existing ID, exact owner/set) | Released | conflict | +| Activate(existing ID, other owner/set) | 任意 | conflict | +| ReleaseActive | Active,且 owner retention 已结束或 owner 不存在 | Released | +| ReleaseActive | Released | 幂等成功 | +| ReleaseActive | 不存在 | not found | + +**ART-RET-3** `ClaimsByOwner` 使用 watermark cursor,按 ClaimID 稳定排序;空 owner identities 不匹配。回收前核对:GC 在按 Active claim 计算 root 之前,对每个 Active claim 调用 `OwnerVerifier.OwnerExists`,不存在则 `ReleaseActive`;这一步清理 EXT-WRT-3 顺序下可能留下的孤儿 claim。核对只能在该 owner 的写入路径不可能仍在进行时执行:Session 部署中即该 Session 没有进行中的 `Writer.Commit`,参考实现在 `OpenWriter` 完成日志重建之后、接受第一个 Commit 之前对该 Session 的 claim 核对一次,运行期的核对必须与 Writer 互斥。`ReleaseActive` 的另一种授权(owner retention 已结束)由 Application 的 GC policy 提供。 + +## 6. provider 与 scheme boundary + +```go +type SchemeDefinition struct { + Scheme Scheme; SupportedDurabilities []Durability + ValidateRef func(Ref) error +} +type ProviderDescriptor struct { + KindID ProviderKindID; Schemes []Scheme; ConfigSchema jsonstable.Value +} +type ProviderBinding struct { + Scheme Scheme; Authority Authority; InstanceID ProviderInstanceID +} +``` + +**ART-PRO-1** registry 在 startup 组合后 immutable;每个 Scheme 有唯一 definition,verified use 需要已注册 Scheme 和唯一 `(Scheme,Authority)` provider binding。provider config、secret、物理位置与迁移属于 adapter/Application。 + +## 7. archive 与 import/export(附录,不进入 v1) + +以下为预留设计,v1 实现返回 `ErrUnsupported`。 + +```go +type BindingManifest struct { + WireVersion WireVersion; Bindings []Binding; ActiveClaims []RetentionClaim +} +type InspectionStatus string +const ( + InspectionAccepted InspectionStatus = "accepted" + InspectionUnknownScheme InspectionStatus = "unknown_scheme" + InspectionInvalid InspectionStatus = "invalid" +) +type InspectionResult struct { Status InspectionStatus; Detail string } +type VerifiedImportResult struct { Bindings []BindingID; Claims []ClaimID } +``` + +**ART-ARC-1** manifest 是精确 canonical wire:Bindings 按 BindingID 严格递增、ActiveClaims 按 ClaimID 严格递增,且只可携带 `Active` claims。inspection 可以无损接受未知 scheme record,但不创建可用 Binding 或 claim;verified import 要求 codec、排序、Binding digest、RefSetDigest、scheme/provider 和 object policy 全部通过。 + +**ART-ARC-2** `ImportActiveClaims` 是 all-or-nothing validation boundary:先验证所有 referenced Binding、durability、digest、owner、ClaimID 和 state,再全部写入或失败。它不接受 Prepared 或 Released records;逐字段相同 active record 幂等,同 identity 的不同 record 为 conflict。导出按 `ClaimsByOwner` 的完整 cursor 枚举 closure。 + +**ART-PRO-2**(附录)adapter 改变物理实现时必须保持 locator resolution 不变,并以 generation/fence 防止旧位置在新位置验证可恢复前回收。具体 filesystem、DB 与迁移步骤由 adapter/Application 负责。 + +**两阶段 claim**(附录)当 ledger 与 owner fact 不在同一事务域时,`Prepare(ClaimID, Owner, Set)` 建立 `Prepared` claim 作为 in-flight GC root,`Activate` 在 owner fact 确认后转为 Active,`AbortPrepared` 只在 owner operation 已 terminally aborted 的 durable evidence 下转为 Released;reconciler 扫描 `PreparedClaims`,对 NotFound、unknown 或 transient failure 保留 Prepared。对应 Session Module Framework 附录 C。 + +多 package coordination、quarantine 操作流程不属于本规范。 + +## 8. errors 与 conformance + +```go +type ErrorCode string +const ( + ErrInvalid ErrorCode = "invalid"; ErrNotFound ErrorCode = "not_found" + ErrConflict ErrorCode = "conflict"; ErrUnauthorized ErrorCode = "unauthorized" + ErrExpired ErrorCode = "expired"; ErrCorrupt ErrorCode = "corrupt" + ErrUnsupported ErrorCode = "unsupported"; ErrUnavailable ErrorCode = "unavailable" +) +type Error struct { Code ErrorCode; Operation string; Identity string; Detail string } +func (Error) Error() string +``` + +实现必须以可判别 `ErrorCode` 返回预期失败;`Detail` 不得承载 provider secret。 + +v1 conformance 必须验证: + +- **ART-ID-1、ART-REF-1、ART-REF-2、ART-WIR-1**:canonical round-trip、拒绝歧义 wire、identity-bound/untrusted MediaType、locator/integrity 和 durability; +- **ART-BND-1、ART-BND-2、ART-CAP-1**:Binding conflict、promotion、resolver integrity 和 capability errors; +- **ART-RET-1、ART-RET-2、ART-RET-3**:BindingSetBuilder/ledger 独立重算与精确验证、RefSetDigest、不可复用 released claim、两态状态表、`Activate` 返回即持久且幂等、owner 不存在的 Active claim 被回收前核对释放而 owner 存在的不受影响、cursor pagination、Active GC protection; +- **ART-PRO-1**:immutable registry 与 provider-instance isolation; +- **ART-SCP-2**:附录能力返回 `ErrUnsupported`。 diff --git a/docs/design/agent-reference-assembly.md b/docs/design/agent-reference-assembly.md new file mode 100644 index 0000000..b785456 --- /dev/null +++ b/docs/design/agent-reference-assembly.md @@ -0,0 +1,171 @@ +# Twilight Agent 参考组装 + +状态:设计草案。`agent/ref` 已实现 Agent 配置面(Profile)、ContextPlanner、Memory 组装、宿主驱动(Memory.Drive)、SessionDriver 与 Session 宿主。与 [Run](agent-run.md)、[Turn](agent-turn.md)、[Chatlog](agent-session-chatlog.md) 冲突时以各正式规范为准。 + +补充说明:ContextPlanner 把回合中途投递的输入排在其之前尚未结算的工具结果之后。原因是这类输入的 `input_delivered` 先于 `tool_result` 进入 stream,而 provider 要求工具结果紧随发出调用的 assistant 消息。fold 顺序不变,只影响请求组装。 + +本文规定 Memory 参考 agent 的六处组装:Agent 配置面(Profile 公开字段与 digest 边界)、Planner、用户正文在 Chatlog Input 与 Run AgentInput 上的同一份 payload、驱动(Memory.Drive——Coordinator 只做协议提交与状态读取,驱动的生命周期属宿主)、session 作用域的输入路由(SessionDriver)、宿主对象(Session)。 + +## 1. Agent 与 Profile + +Agent 是一个可注册的执行配置:持久的公开配置(Profile)加上解析它的进程内能力。Session 只保存 `turn.ProfileRef{ID, Digest}`;密钥、client 与工具实现留在进程内,重启后以同一公开配置重新注册即可继续解析。 + +```go +type Agent interface { + Profile() Profile + ResolveModel(run.ModelRef) (loop.ModelInvoker, error) + ResolveTool(run.ToolRef) (loop.ExecutableTool, error) +} +// 常见形态(一个模型 + 一组工具)由构造器组装: +// NewAgent(model run.ModelRef, invoker loop.ModelInvoker, opts ...AgentOption) (Agent, error) +// 选项:WithTool、WithSystemPrompt、WithStreaming、WithPolicy。 +// 自定义 catalog 直接实现 Agent 接口。可选接口 PolicyProvider 提供 loop.ExecutionPolicy。 + +type PublicTool struct { + Ref run.ToolRef + Definition run.ToolDefinition + Policy run.ResponsePolicy +} +type Profile struct { + SchemaVersion uint16 // 1 + Model run.ModelRef + Tools []PublicTool // ToolSpec 与 Request.Tools 都由此派生 + Streaming bool + SystemPrompt string // 在 digest 之外 +} +``` + +**REF-BND-1** `Digest = Digest("twilight/ref/profile", canonical(Profile 去除 SystemPrompt))`。digest 只覆盖影响重放正确性的字段(SchemaVersion、Model、Tools、Streaming);SystemPrompt 是调优文本,修改它不得使可恢复的 Turn 无法 Resolve。 + +**REF-BND-2** `Agents.Register(id, agent)` 在注册时构建 driver 并返回 `ProfileRef`;`Resolve(ref)` 在 Digest 与注册 agent 的当前 Profile 匹配时返回该注册的 driver,未注册或 digest 不匹配为 `ErrProfileUnavailable`。`RunDriver`、`ProfileRegistry` 与 `ErrAlreadyDriving` 都是宿主层(ref)的合同,turn 协议不感知它们。同一注册的所有 drive 共享一个 Loop 实例,因此同一 Run 的第二个本地驱动者确定地得到 `ErrAlreadyDriving`(REF-DRV-1),而非与首个驱动者并发驱动。同一 Run 内同一 ModelRef 的解析语义保持等价(RUN-LOP-7)。 + +**REF-BND-3** 参考 Planner 的 `RequestPlan.Model` 等于 `Profile.Model`。 + +## 2. Planner + +参考 Planner 为 context-v1;装配只有这一个 Planner,Profile 不记录 Planner 标识(第二个 Planner 出现时随 Planner 注册表重新引入)。 + +```go +func Plan(ctx context.Context, hint run.PlanningHint, fold []chatlog.Entry, profile Profile) (loop.RequestPlan, error) +``` + +**REF-PLN-1** `fold` 为 `ContextFold` 对该 Session chatlog 事件的输出(含已应用的 checkpoint)。Planner 在每次 Plan 时经该 Session Writer 的 `Projections()` 读取 `twilight/chatlog/context` 投影(EXT-PRJ-4)。 + +**REF-PLN-2** `sdk.Messages` 顺序: + +1. `profile.SystemPrompt` 非空时一条 system message; +2. 按 `fold`:`input` → user;`assistant` → assistant(ToolCallPart 的 `ProviderCallID` 写入 `sdk.ToolCallPart.ToolCallID`);`tool_result` → tool(以同 Turn assistant 中同 CallID 的 `ProviderCallID` 配对);`summary` → assistant text。 + +上一步的 assistant 与 tool_result 已随对应 Run 事实同 commit 提交,Planner 消费时的 fold 总是包含它们;`PlanningHint` 不携带模型结果或工具结果。 + +**REF-PLN-3** `hint.Inputs` 与本 Turn 已 delivered、且属于本次 Prepare 的 Input 按 ID 对齐,包括回合中途经 Deliver 进入的输入。这些 Input 的 `input_delivered` 与 `input_accepted` 同 commit,Plan 时一定已在 fold 中,只使用 fold。 + +**REF-PLN-4** `RequestPlan.Model = profile.Model`;`Request.Tools` 与 `Tools`(ToolSpec:Ref、DefinitionDigest、Policy)都由 `profile.Tools` 派生,顺序一致;`InputIDs` 为本次消费的 PendingInput IDs。`PlanningToken` 随 fold 的 Entry digest 序列或 Profile Digest 变化。 + +**REF-PLN-5** 无附件时 TextPart 直接写入 sdk.Message。ReferencePart 经 ContextMaterializer 转换。 + +**REF-PLN-6** 同一 Turn 有多个 Run attempt 时,参考 Planner 把全部 attempt 的 assistant 与 tool_result 按 commit 顺序纳入请求,包括失败 attempt 的部分输出与 status=`unknown` 的工具结果。这与用户中断后继续的语义一致。Application 可以替换为其他策略(例如排除 `AttemptView.End` 为 failed 的 attempt 的条目),策略只影响请求组装,不影响 stream 与 ContextFold。 + +## 3. 用户正文 + +同一份 canonical JSON: + +```text +twilight/chatlog/input_submitted.Content +run.AgentInput.Payload +``` + +**REF-INP-1** v1 形状为 `{"text":"<用户字符串>"}`。 + +**REF-INP-2** `StartRequest.Inputs[i].ID` 等于已 submitted 的 InputID,`Payload` 等于该 Input 的 Content。`input_delivered` 把 InputID 挂到 TurnID;`twilight/run/input_accepted` 在同一 commit 把同一 payload 交给 Run。`Memory.SubmitText` 以 `NewInputID()`(随机、跨重启无碰撞)提交;需要外部幂等键的调用方使用 `SubmitInput`。 + +**REF-INP-3** Planner 把 `{"text":...}` 投影为 sdk user text。 + +## 4. 驱动与 SessionDriver + +Coordinator 只做协议提交与状态读取(TRN 3);**驱动的生命周期整体属于宿主**:何时驱动、驱动 goroutine 的归属、取消与超时、结果组装都是宿主决定。参考组装提供两层:`Memory.Drive`(单次驱动到静止点)与 session 作用域的 `SessionDriver`(把用户输入路由到 Deliver 或 Start、提交后驱动、结算后开启下一个 Turn)。两者都没有自己的持久状态,不进入 turn 或 run 协议。 + +**REF-DRV-1** `Memory.Drive(ctx, ref)`:读 `twilight/turn/surface`,Turn 为 `active` 时以 `Resolve(view.Profile)` 取 driver(REF-BND-2),调用 `driver.Drive(ctx, {Ref, RunID: ActiveRun})`——driver 内部为 `loop.Run(ctx, runtime, SessionID, RunID, sink)`;随后(或 Turn 非 active 时直接)调用 `Coordinator.Status` 组装响应(TRN-STA-1)。driver 返回 `ErrAlreadyDriving` 时转为成功响应并置 `ResumeAlreadyDriving`:提交的输入由运行中的驱动者继续推进,调用方不经错误通道分辨这一情形。驱动受调用方 ctx 约束:取消是宿主决定,被取消的驱动使 Turn 保持 `active`,下次 Open 后再驱动即恢复。 + +```go +type SessionDriver struct { + Coordinator turn.Service + Writers extension.Writers // 读投影经 Writer.Projections() + Profile turn.ProfileRef // 新 Turn 使用的 Agent Profile + Companion turn.CompanionVersion + NewTurnID func() turn.TurnID // nil 时使用随机默认 +} +func (d *SessionDriver) Send(ctx, sid session.SessionID, inputs []run.AgentInput) (turn.TurnResponse, error) +func (d *SessionDriver) OnTurnSettled(ctx, sid session.SessionID) (turn.TurnResponse, bool, error) +``` + +**REF-DRV-2** `Send` 先读 `twilight/turn/surface`:存在 `active` 的 Turn 时调用 `Deliver`,输入进入该 Run 的下一步;否则以 `NewTurnID()`、`Profile`、`Companion` 调用 `Start`。提交成功后进入 `Memory.Drive`(REF-DRV-1)。这对应 inbox 模型中"steer 在运行中注入下一步、在空闲时开启新 turn"的行为。输入在两种情形下都已由 Application 先写入 `input_submitted`。 + +**REF-DRV-3** `OnTurnSettled` 在 Turn 进入 `completed`、`failed`、`stopped` 或 `superseded` 后调用:读 `twilight/chatlog/surface`,若存在 `submitted` 且未 delivered 的输入,按 `input_submitted` 的 stream 顺序取全部,`Start` 新 Turn 并返回;否则返回 `false`。这对应 inbox 模型的 `next-turn` 列表:已提交而未投递的输入就是该列表,不需要另一份持久结构。 + +**REF-DRV-4** Turn 为 `attempt_failed` 时 `Send` 返回 conflict,不自动 Retry 或 Settle;这两者是 Application 的决定。`Deliver` 与最后一步结果并发失败(TRN-DLV-3)时,`Send` 得到 `completed`,输入仍为 `submitted`,随后的 `OnTurnSettled` 会把它带入下一个 Turn。 + +**REF-DRV-5** 崩溃恢复:`SessionDriver` 从两个投影重建。对每个 session,先经 `Writers` 取得 Writer(新 Epoch),调用 `Runtime.RecoverInterrupted` 处置全部 Executing 目标(RUN-CMT-7),再按 TRN-REC-1 对 `active` 的 Turn 调用 `Memory.Drive`、对 `attempt_failed` 的 Turn 由 Application 选择 Retry 或 Settle;没有未结算 Turn 时调用 `OnTurnSettled` 消费积压的输入。 + +## 5. Session 宿主 + +宿主面对的单一对象:`ref.Session` 把 EnsureSession、所有权打开、接管处置、输入提交、路由、结算后排空积压与回复读取收拢为一个 API。turn 层只报协议结果(Status/Disposition/Attempt);回复文本是对话层概念,由宿主从 chatlog 读出。 + +```go +func (m *Memory) OpenSession(ctx, sid, SessionOptions{Profile, Companion, ResumeActive}) (*Session, error) +type Result struct { TurnID; Status; Disposition; Reply string } +func (s *Session) Send(ctx, text string) ([]Result, error) +func (s *Session) Resume(ctx) ([]Result, bool, error) +func (s *Session) Retry(ctx) ([]Result, bool, error) +func (s *Session) Status(ctx) (SessionStatus, error) // Active 与待 Retry/Settle 的 Turn +func (s *Session) Close(ctx) error // 只释放本 Session 的 Writer +``` + +**REF-SES-1** `OpenSession` 依次:确保 stream 存在(先 `Header` 探测再 `Create`——Create 的幂等要求字段全同,重启后 `CreatedAtUnixMilli` 必然不同)、按装配的 Ownership 打开 Writer、`RecoverInterrupted`;接管处置数暴露为 `Session.Recovered`。`ResumeActive` 为真时同步 Resume 仍在 `active` 的 Turn;交互式宿主保持 false、自行在后台调用 `Resume`。 + +**REF-SES-2** `Send` 提交文本(`SubmitText`)、路由并驱动(REF-DRV-2)并阻塞到结算:首个 `Result` 是输入落入的 Turn,其后是本次调用在结算后从积压开启并结算的 Turn(REF-DRV-3 的循环,内化在宿主里)。`Disposition` 为 `already_driving` 时该输入由运行中的驱动者推进,本次调用不再排空。`Reply` 为该 Turn 最后一条 assistant 的 TextPart 拼接,仅在 `finished` 时读取。 + +**REF-SES-3** 并发 `Send` 安全:写入由该 Session 的 Writer 串行化。路由竞态(两个 Send 同时判定 Start,或投递瞬间结算)表现为 `turn.ErrConflict`,宿主重试路由;重试前发现输入已被其他驱动者投递时,返回 `already_driving` 的 `Result`(该 Turn 在取走它的调用里结算与报告)。 + +**REF-CKP-1**(compaction:机制在 chatlog,策略在宿主)`Memory.Checkpoint(ctx, sid, summaryText, retain)` 在该 Session Writer 的 Commit 临界区内读 `twilight/turn/surface`(存在 active Turn 则拒绝——compaction 是回合之间的操作)与 `twilight/chatlog/context`,以 `View.Head().Next - 1` 为 `CoveredThrough`,把 summary 与 `checkpoint_created` 同组提交(CHT-EVT-3)。`Session.Compact` 用 profile 的模型生成摘要:这是宿主级模型调用,不属于任何 Run,生成中崩溃不写任何事件;随后以 `RetainLast` 的配对封闭后缀提交 checkpoint。自动策略由 `SessionOptions.CompactAfterEntries` 启用:结算且积压排空后、Context 条目数超阈值时触发;失败经 `CompactWarn` 上报,不改变已结算的 `Result`。 + +**REF-CKP-2**(retained 配对封闭)retained 集必须封闭:保留的 tool_result 连同签发该 call 的 assistant,保留的带 tool_call 的 assistant 连同其在 Context 中的 result——否则压缩后的 Context 组装不出合法的 provider 消息序列。`RetainLast(entries, n)` 返回满足封闭的最短后缀(孤儿 result 向前扩窗到其 assistant);`Memory.Checkpoint` 校验封闭并拒绝违反者。子集与顺序由 fold 校验(CHT-EVT-3),封闭由宿主校验,两者各管一层。 + +## 6. Memory 组成 + +```text +sessionStore = session.NewMemoryStore() // Create、Header、Open、Read(SES 第 4 至 6 节) +registry = extension.BuildRegistry(protocolVersion, chatlog.Module, turn.Module, runmod.Module, opts.Modules...) // app module 经 Options.Modules 注册(EXT 第 8 节) +bindingStore = artifact.NewMemoryBindingStore() +ledger = artifact.NewMemoryLedger(bindingStore) // 自持久化;claim 先于 Append 建立 +writers = extension.NewWriters(sessionStore, registry, ledger, openOptions) // 每 Session 一个 Writer(EXT-WRT-6) +runtime = runmod.NewRuntime(writers, runmod.NewMemoryFrozenValues(), turn.CompanionV1(registry)) // ProjectionCache 可选,参考装配不注入 +agents = Agents.Register(id, agent) -> driver = loop.New(agent, agent, contextPlanner, policy, profile.Streaming) // 每注册一个 Loop +coordinator = turn.Coordinator{Writers: writers, Runtime: runtime} // 纯协议:提交 + Status;不驱动 +driver = SessionDriver{Coordinator: coordinator, Writers: writers, Profile: profileRef, Companion: turn.CompanionV1Version} // 提交后经 Memory.Drive 驱动 +host = Memory.OpenSession(sid, {Profile: profileRef}) // ref.Session + +input_submitted +driver.Send // 无 active Turn → coordinator.Start(提交)→ Memory.Drive + 组 1: twilight/turn/started + twilight/chatlog/input_delivered* + twilight/run/created + twilight/run/input_accepted* + Loop.Run + 组: twilight/run/model_step_prepared (请求本体 → FrozenValueStore) + 组: twilight/run/model_step_started + 组: twilight/run/model_step_completed + twilight/run/tool_step_opened + twilight/chatlog/assistant + 组: twilight/run/tool_call_started + input_submitted; driver.Send // 有 active Turn → coordinator.Deliver + 组: twilight/run/input_accepted + twilight/chatlog/input_delivered + 组: twilight/run/tool_call_completed + twilight/chatlog/tool_result + 组: twilight/run/model_step_prepared (PlanningHint.Inputs 含中途输入) + ... + 组: twilight/run/model_step_completed + twilight/run/ended + twilight/chatlog/assistant + twilight/turn/completed +driver.OnTurnSettled // 有积压的 submitted 输入 → 开下一个 Turn(Session.Send 内化了这一步) +``` + +进程重启:writers.Writer(sid) 以新 Epoch 打开 → runtime.RecoverInterrupted(sid) → 对 active 的 Turn 调用 Memory.Drive(REF-DRV-5) + +每一行"组"是一次 `Writer.Commit`,落为 stream 中 CommitID 相同、Index 连续的若干行(SES-APP-1)。 + +参考 agent 的工具 ResponsePolicy 为 `DirectExecution`。ContextFold 在无 checkpoint 时输出全部有效条目。 + +**REF-MEM-1(app module 开口)** `Options.Modules` 把 application module(EXT 第 8 节)追加进 Registry,须使用自有 Source。app module 的读写走既有入口,装配不另设通道:写事件经 `Memory.Writers` 取该 Session 的 Writer 后 `Commit`(与 `SubmitInput` 同路径);读自己的投影经 `Memory.Projection(ctx, sid, id, version)`(`ChatlogSurface`/`TurnSurface` 是它对 first-party 投影的封装)。 diff --git a/docs/design/agent-run.md b/docs/design/agent-run.md new file mode 100644 index 0000000..f8c26c6 --- /dev/null +++ b/docs/design/agent-run.md @@ -0,0 +1,589 @@ +# Twilight Agent Run Protocol + +状态:设计规范。Machine、command/fact 规则、Loop 与第 5 节的 Runtime(`agent/session/run`)均已实现:Runtime 经 `extension.Writer` 写入,无 lease/grant,`RecoverInterrupted` 为接管处置;RUN-CMP-2 conformance 在 `agent/session/run/runtimetest` 以 Store 为参数,对 Memory Store 通过。本文依据 [agent-session.md](agent-session.md)(Session 级单写者、一行一个 event)与 [agent-session-extension.md](agent-session-extension.md)(`extension.Writer`);实施记录见 [agent-runtime-refactor.md](agent-runtime-refactor.md)。 + +本文定义 `agent/run`、`agent/run/loop` 与 Run 作为 Session Module 的存储形态。文中的"必须""不得""应该"是协议约束;canonical JSON、JCS 与 domain-separated digest 使用 `agent/jsonstable` 和 `agent/es` 的通则。 + +## 1. 范围与 authority + +```text +Session stream 唯一 authority:twilight/run/ 事实与 turn、chatlog 事件同在一条 stream,一行一个 event +MachineState Run 的语义状态投影(twilight/run/machine);投影缓存为可丢弃的派生缓存 +Runtime Run 的 command 入口:在 Session 的 extension.Writer 内 Decide、Evolve、companion,一次 Append +loop.Loop 当前进程的 execution interpreter +FrozenValueStore 内容寻址旁存:模型请求本体(含工具定义),按 digest 存取 +``` + +`MachineState` 决定 Run 当前可执行动作。每次接受的 command 产生一组同 CommitID 的 Session event,其中的 `twilight/run/` 事件经该 Run 版本的 `Protocol.Evolve` 从 `twilight/run/created` 重放后必须得到同一 `MachineState`。 + +Run 的职责分成五个相互独立的层面: + +```text +Agent Machine = Run/Step 状态与合法转移(Decide、Evolve、Next) +Agent Loop = Machine effect 的进程内解释器 +Runtime = command 到 Session event 组的原子提交边界、接管处置 +Model / Tool = 一次模型请求或一次工具调用的 effect 执行器 +Request Planner = Session context 到 sdk.Request 的投影器 +``` + +Machine 处理已冻结的值和已提交的事实;Loop 解释 `Next` 产生的 transient effect;Runtime 保存并验证 Machine 的推进;Model/Tool 执行一次外部 effect;Request Planner 组装下一次模型请求。 + +`Step` 是 Run 的持久化恢复边界;`execution attempt` 表示某个 Loop 进程对该 Step 或 ToolCall 的一次易失执行。一个 Step 可以有多个 attempt。执行所有权是 Session 级的(SES-OWN-3):持有该 Session `Writer` 的进程拥有其中全部执行,Run 不设按目标的 grant 或 lease。attempt 的 identity 由 start command 的 `ExecutionClaim` 表达;它不进入 stream。 + +**RUN-SCP-1** `agent/run` 拥有 Run identity、persisted frozen values、Machine、command/fact protocol、fact codec、fold 与 `Runtime`、`Companion` contract;它依赖 `agent/session` 的 identity 与 wire 类型,不依赖 loop、turn 或 extension。`agent/run/loop` 拥有 planner/model/tool ports、streaming、并发执行、EventSink 与 Loop policy。`agent/session/run` 是 Run 的 Session Module 实现:EventDefinition(按 SchemaVersion 的 codec)、`twilight/run/machine` projection、`Runtime` 实现(经 `extension.Writer.Commit` 写入)、接管处置、FrozenValueStore adapter。 + +**RUN-SCP-2** Run 是 first-party Session Module(Source `twilight`,ModuleID `run`)。Run 不解释它的上层实体:`OwnerID` 是 opaque 字符串,由 turn 模块以 TurnID 填充。本模块的 `Requires`(EXT-REG-4)为空;`Companion` 是 Runtime 的构造参数,由组装代码注入,为 nil 时构造失败,不作为模块依赖声明。Turn 的创建、attempt 归属与结算、Run 事实到对话内容的 companion 映射由 [agent-turn.md](agent-turn.md) 定义;对话内容 ontology 由 [agent-session-chatlog.md](agent-session-chatlog.md) 定义;stream、所有权、组追加与投影机制由 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md) 定义。Artifact、queue、provider registry、权限与产品 policy 分别由其 package 或 Application 拥有。 + +## 2. identity、persisted values 与 wire + +```go +type RunID string +type OwnerID string // 上层实体标识,Run 不解释 +type StepID string +type CallID string +type CommandID string +type ResponseID string +type InputID string +type ToolRef string +type ModelRef string +type PlanningToken string +type ExecutionClaim string +type Digest = es.Digest +``` + +**RUN-WIR-1** identity 必须非空、稳定且为有效 UTF-8。`ExecutionClaim` 由 Loop 为一次 start command 生成并在该 command 的重试中保持不变,用于把同一执行尝试的 start 与 settlement 派生为确定的 CommandID;接管处置使用 `TakeoverClaim = Digest("twilight/run/takeover", SessionID, Epoch)`(RUN-CMT-7)。Claim 不进入 fact。Run 跨 domain causation 记录在 `twilight/run/created` 的 `CausationID`。 + +Run 持久化协议保存 run-owned frozen values。模型请求、模型结果、消息、工具定义、usage、provider metadata 与所有动态 JSON 在进入 command 前,分别经 `FreezeModelRequest`、`FreezeModelResult`、`FreezeToolDefinition`、`FreezeToolCallInput` 等入口转为纯数据和 immutable `CanonicalJSON`。Runtime 接收 agent-owned value;调用方负责在边界前完成冻结。 + +**RUN-WIR-2** Run 事实是 Session event:EventType 为 `twilight/run/`,payload 为 canonical JSON object,第一层携带 `runId` 与 payload 版本字段 `v`(SES-VER-1、EXT-REG-2)。`v` 等于该 Run 的 `SchemaVersion`:由 `twilight/run/created` 记录,同一 Run 的全部事实使用同一值,Registry 永久保留每个已发布版本的 codec、Decide 与 Evolve。行字段(Seq、CommitID、Index、Last、digest)由 Session kernel 提供,Run 不另设 envelope。fact codec 必须拒绝 unknown type、duplicate key、unknown field、trailing data、非法 UTF-8、非 canonical-equivalent wire。精确 identity 和 digest 使用 JSON string,整数字段使用 Session profile 的整数 wire shape。 + +```go +type CommandEnvelope struct { + SchemaVersion uint16 // 必须等于该 Run 的 created.SchemaVersion + Type string + SessionID session.SessionID + RunID RunID + ID CommandID + Command AgentCommand +} +``` + +command 不持久化。`CommandEnvelope.ID` 就是该 command 产生的 event 组的 `CommitID`;重放与冲突由 Writer 的行 fingerprint 判定(EXT-WRT-2)。envelope 只经 `Protocol.BuildEnvelope` 构造(RUN-WIR-3),不携带自校验 digest。 + +**RUN-WIR-3** 一个 command 恰产生一组事件(一次 `Append`,同一 CommitID);其 `twilight/run/` 事件在组内 Index 从 0 连续递增,companion 事件(TRN-CMP)与调用方附加事件(`CommitRequest.Attach`)依次紧随其后。事件没有独立 EventID,`Seq` 即身份(SES-WIR-1)。Runtime 提交的组其 CommitID 等于 CommandID,Coordinator 写入的 Start 与 Retry 组使用该组自己的 CommitID。`RecordedAtUnixMilli` 由写入方的时钟填入,是 metadata,不参与 Run 的任何派生,也不进入 Writer 的幂等 fingerprint(EXT-WRT-2)。构造 command 必须使用该 Run 版本的 `Protocol.BuildEnvelope`(Loop 通过 `RuntimeSnapshot.Protocol()` 取得)。`agent/run` 不提供隐式选择版本的包级 `BuildEnvelope`、`Decide`、`Evolve` 或 `Digest*` 函数;新 Run 与测试显式使用 `ProtocolV1()`。 + +**RUN-WIR-4** 内容与执行状态分离。fact 只保存执行状态与内容 digest,内容本体落在两处: + +| 内容 | fact 中的字段 | 本体位置 | +|---|---|---| +| 冻结模型请求 `ModelRequest`(含工具定义) | `ModelStepPrepared.RequestDigest` | `FrozenValueStore`,key 为 RequestDigest | +| 工具定义 `ToolDefinition` | `ToolSpec.DefinitionDigest`,只用于执行前校验 | 请求本体内;不另设存储 | +| 模型输出文本、reasoning、tool call 列表 | `ModelStepCompleted.ResultDigest` | 同组的 `twilight/chatlog/assistant`,其 `SourceDigest` 等于 ResultDigest | +| 工具输出 | `ToolCallCompleted.OutputDigest` / `ToolCallAnswered.ResponseDigest` | 同组的 `twilight/chatlog/tool_result`,其 `SourceDigest` 等于该 digest | +| tool call 参数 | `ToolCallBinding.Arguments` | fact 本身(执行不得依赖 chatlog 解码) | + +companion 与 Attach 事件与 Run 事实一起经 Module Framework 的 admission(EXT-REF-2):它们可以携带 `ReferencePart`,其 Binding 的 claim 由 Writer 在 `Append` 之前建立(EXT-WRT-3)。`FrozenValueStore` 是内容寻址存储:`Put(digest, bytes)` 幂等,`Get(digest)`。请求本体的有效期是该 ModelStep 从 Prepared 到终结;step 终结后 adapter 可按保留策略删除或归档,Record 校验不依赖本体。适配器有内存实现(`run.MemoryFrozenValues`)与文件实现(`agent/session/filestore.NewFrozenValues`,与 session 目录同根落盘);宿主用文件实现时,进程重启后 `RecoverModelExecution` 的重放可从盘上取回请求本体。工具列表摘要(`DigestToolSpecs`)的预映像不区分 nil 与空列表:fact wire 省略空列表,重算方拿到的是 nil。 + +下列 identity 稳定派生并由 Commit 验证: + +| identity | preimage | +|---|---| +| PrepareModelRequest CommandID | RunID、loaded `RunPosition`(该 RunID 最后一条事件的 Seq) | +| ModelStep StepID | RunID、prepare CommandID、model/request/tools binding digest | +| ToolStep StepID | source ModelStepID、ordered binding-set digest | +| CallID | source ModelStepID、该 call 在模型结果 `ToolCalls` 中的位置 | +| ResponseID | RunID、ToolStepID、CallID、ResponseKind | +| response CommandID | RunID、StepID、CallID、ResponseID | +| input CommandID | RunID、InputID | +| withdraw CommandID(WithdrawPreparedStep) | RunID、StepID | +| start CommandID(StartModelExecution / StartToolCall) | RunID、StepID、CallID(model 为空)、Claim | +| owner settlement CommandID(model result/failure/reject、tool result/failure) | RunID、StepID、CallID、Claim | +| Pending Known failure CommandID | RunID、StepID、CallID、空 Claim | +| model recovery CommandID(RecoverModelExecution) | RunID、StepID、Claim | +| tool recovery CommandID(接管处置的 Unknown) | RunID、StepID、CallID、TakeoverClaim | + +派生 identity 使同 CommandID 即同一 command:内容差异只可能出现在 identity 有意不覆盖内容的两族(同一 ResponseID 的 approve 与 reject、同一 attempt 的两次结算),Runtime 对它们按精确重放处理,调用方从投影读取实际生效的结果。`PlanningToken` 是 Application-owned opaque freshness token,属于 prepare command identity 内容;Run 不校验它的语义(RUN-CMT-4)。 + +## 3. 创建与 canonical record + +```go +type NewRun struct { + SchemaVersion uint16 + RunID RunID + Owner OwnerID + Attempt uint32 + CausationID es.CausationID +} +type RunCreated struct { + SchemaVersion uint16 + RunID RunID + Owner OwnerID + Attempt uint32 + CausationID es.CausationID +} +type RunRecord struct { + Created session.Seq + Snapshot RuntimeSnapshot + Events []session.SessionEvent // 该 RunID 的全部 twilight/run/ 事件,按 Seq 顺序 +} +``` + +**RUN-NEW-1** `twilight/run/created` 是 Run 的第一个事实。v1 初始状态恰为:相同 RunID、Owner、Attempt、`RunActive`、`Current=Open`、无 pending input、零 model step、零 usage、无 result。初始输入随后以 `twilight/run/input_accepted` 进入同一组(TRN-STR-2)。`Protocol.BuildCreateGroup(NewRun, []AgentInput)` 返回 `created` 与 `input_accepted` 的 facts,编码为 Session event 由 `agent/session/run` 完成,Coordinator 不自行编码。同一 RunID 第二条 `created` 为 Evolve 错误。 + +**RUN-NEW-2** `FoldRun(events)` 按 Seq 顺序折叠该 RunID 的完整事件序列,第一条必须是 `created`,并按其 `SchemaVersion` 绑定 `Protocol`。Fold 过程执行纯状态重建。import、诊断与 `Runtime.Record` integrity verification 都经 FoldRun;投影缓存通过 FoldRun 结果校验。 + +## 4. Machine + +```go +type Current interface{ current() } +type Open struct{} +func (Open) current() {} +func (ModelStep) current() {} +func (ToolStep) current() {} + +type MachineState struct { + RunID RunID + Owner OwnerID + Attempt uint32 + Status RunStatus + Current Current + PendingInputs []AgentInput + ModelSteps int + LastToolStep *ToolStep + Usage Usage + Result *RunResult +} + +type RunResult struct { + Status RunStatus + Reason RunReason + Failure *RunFailure + UncertainCalls []CallID + UncertainModel StepID + Usage Usage +} + +type ModelStepStatus uint8 // Prepared | Executing +type ModelStep struct { + RefValue StepRef + RequestDigest Digest // 本体在 FrozenValueStore + Model ModelRef + Tools []ToolSpec + ToolsDigest Digest + Status ModelStepStatus + Rejects int // 已接受的 ModelStepRejected 次数;不进入 RefValue.Digest +} +type ToolSpec struct { + Ref ToolRef + DefinitionDigest Digest // 本体在请求内 + Policy ResponsePolicy +} +type ToolScheduleMode string // "parallel" | "sequential";空值按 parallel 解释 +type ToolScheduling struct { + Mode ToolScheduleMode + MaxParallel int // 0 表示当前 Start 批次全部 Pending call 可并行 +} +type ToolStep struct { + RefValue StepRef + Source StepID + Calls []ToolCallState + Scheduling ToolScheduling +} +``` + +`Status` 是 Run 的生命周期:`RunActive | RunCompleted | RunStopped | RunFailed`。后三者是终态。`RunStatus` 表示当前 MachineState 的投影;终态 fact 使用 RunEnd union 表达具体结果。 + +`Current` 是 Active 期间的内容。`Open` 是规划区间:可提交 `PrepareModelRequest`,`Next` 返回 `NeedModelRequest`。`ModelStep` 与 `ToolStep` 表示正在进行的步骤。`AcceptInput` 在任意非终态都被接受,只把输入追加到 `PendingInputs`;`PendingInputs` 是回合中途追加输入的持久化队列,在下一次 Prepare 时被一次消费。终态的 `Current` 为空;终态由 `Status` 表达,不另设 Current variant。Active 的 `Current` 不得为空。`Step` 仍只有 `ModelStep` 与 `ToolStep`,提供 `Ref()`。 + +MachineState 不保存模型输出与工具输出本体。上一步的内容由 Planner 从 chatlog fold 读取(REF-PLN),MachineState 只提供 `LastToolStep` 作为 Run 边界事实。 + +终态 fact 使用 Go 的 sealed-union 形式,终态结构由合法的 RunEnd variant 构成: + +```go +type RunEnd interface{ runEnd() } + +type RunCompletedEnd struct{} +type RunStoppedEnd struct { + Reason RunReason + UncertainCalls []CallID + UncertainModel StepID +} +type RunFailedEnd struct { + Reason RunReason + Failure RunFailure +} + +func (RunCompletedEnd) runEnd() {} +func (RunStoppedEnd) runEnd() {} +func (RunFailedEnd) runEnd() {} + +type RunEnded struct { End RunEnd } +``` + +`RunEnded.End` 必须恰好是上述三个 variant 之一;`RunStoppedEnd.Reason` 必须非空,`RunFailedEnd.Reason` 必须是失败原因,`RunFailedEnd.Failure.Class` 必须非空。`RunEnded` 是 terminal 组中最后一个 `twilight/run/` 事实。RunStatus、RunResult 等读取模型从该 union 派生。v1 wire 是 tagged union:`{"completed":{}}`、`{"stopped":{reason, uncertainCalls?, uncertainModel?}}` 或 `{"failed":{reason, failure}}`,恰有一个 variant key;codec 拒绝零个或多个 variant、缺失字段与多余字段。Cancel 时仍 Executing 的 tool call 与 model step 必须写入 `RunStoppedEnd` 并投影到 `RunResult`。 + +```text +ModelStep: Prepared -> Executing -> Completed + | | | + | +-> Recovered-+ (回到同一 frozen request 的 Prepared) + | +-> Rejected (retry 回到 Prepared,或同组失败 Run) + +-> Withdrawn -> Open (Prepared 期间有 pending input,放弃该请求并重规划) + +ToolCall: + Pending -> Executing -> Completed + | | + | +-> Failed(Known|Unknown) + +-> Failed(Known) + Waiting(Approval) -> Pending | Failed(Known) + Waiting(ExternalResponse) -> Completed | Failed(Known) +``` + +Recovered 回到 Prepared 后,下一次 Start 重发同一 `RequestDigest` 的请求,Loop 经 `Runtime.FrozenRequest` 取回本体。这是冻结请求被重用的唯一情形;step 终结后下一步由 Planner 重新组装。Prepared 期间到达的输入使该请求不再完整,`Next` 改为返回 `WithdrawPrepared`,Loop 提交 `WithdrawPreparedStep` 后回到 `Open` 重规划;Executing 期间到达的输入等待该步结算,在随后的 `Open` 被消费。 + +**RUN-MCH-1** MachineState 保存 Run 的 execution semantics。`LastToolStep` 保存最近一个经 Evolve 关闭路径写下的 ToolStep 只读投影,必须与事件序列折叠出的最后关闭 step 一致,供下一次 planner 定位 `SourceStep`。Cancel 经 `RunEnded` 把 `Current` 置空、不走关闭路径时不改写 `LastToolStep`。terminal state 吸收所有未幂等命令;`RunEnded` 建立唯一 terminal result。 + +**RUN-MCH-2** `ToolCallBinding` 冻结 CallID、ProviderCallID、ToolRef、definition digest、canonical arguments、response policy 与 binding digest。`CallID` 由 Run 派生(`DeriveCallID(source, index)`),是 Run 内的持久化 identity,进入 fact、派生 CommandID 与 chatlog;`ProviderCallID` 是模型发出的 `tool_call_id`,只用于 Planner 回传工具结果时与模型配对,Run 不以它为键,也不要求它唯一或非空。Decide 校验每个 binding 的 CallID 等于派生值、ProviderCallID 等于模型结果中对应位置的 id。已知工具使用匹配 frozen ToolSpec 的 ref/digest/policy;未知工具保留为同名 unresolved DirectExecution binding,并在执行前收束为已知 lookup failure。approval/external response 的 `ResponseRequest` 由 Decide 稳定派生。Unknown outcome 使用 class `effect_unknown`,只把该 Executing call 记为 `ToolCallFailed(Unknown)`。Run 保持 Active;同 step 其他 call 继续。全部 call 进入 Completed 或 Failed 后 Evolve 关闭 ToolStep。 + +`AgentCommand` 与 `Fact` 都是 sealed interface。v1 的 command→fact 规则为: + +| command | precondition / facts | +|---|---| +| `AcceptInput` | 任意非终态;`InputAccepted`,追加到 `PendingInputs`。同一 InputID 重复接受为错误 | +| `PrepareModelRequest` | `Open`,完整有序消费 PendingInputs,request/tools digests 有效;`ModelStepPrepared`。command 携带请求本体,fact 只留 digest,本体由 Runtime 写入 FrozenValueStore | +| `WithdrawPreparedStep` | Model Prepared 且 `PendingInputs` 非空;`ModelStepWithdrawn`,`Current` 回到 `Open`,该请求本体可释放 | +| `StartModelExecution` | Model Prepared;`ModelStepStarted`。command 必须携带本次 start 的 `ExecutionClaim` | +| `RecoverModelExecution` | Model Executing;`ModelStepRecovered`。携带该 attempt 的 `Claim`,接管处置时为 `TakeoverClaim` | +| `SubmitModelResult` | Model Executing;`ModelStepCompleted{Usage, FinishReason, ResultDigest}`。有 calls 时随后 `ToolStepOpened`(携带冻结的 `Scheduling` 与 bindings);无 calls 且 `PendingInputs` 为空时随后 `RunEnded(completed)`;无 calls 且 `PendingInputs` 非空时 `Current` 回到 `Open`,Run 继续。command 携带冻结 `ModelResult` 本体,companion 写 `twilight/chatlog/assistant` | +| `SubmitModelFailure` | Model Executing;`RunEnded(failed/provider_failure)` | +| `RejectModelResult` | Model Executing;`ModelStepRejected`,由调用方显式选择回到 Prepared 或在同一组追加 `RunEnded(failed/malformed_model_result)` | +| `StartToolCall` | Tool Pending;`ToolCallStarted`。command 必须携带本次 start 的 `ExecutionClaim` | +| `SubmitToolResult` | Tool Executing;`ToolCallCompleted{OutputDigest}`。command 携带输出本体,companion 写 `tool_result`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `SubmitToolFailure(Known)` | Tool Pending/Executing;`ToolCallFailed(Known)`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `SubmitToolFailure(Unknown)` | Tool Executing;`ToolCallFailed(Unknown)`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `ApproveToolCall` | Waiting(Approval);`ToolCallApproved` | +| `RejectToolCall` | Waiting(Approval) 记 `ToolCallFailed(Known/permission_denied)`;Waiting(ExternalResponse) 记 `ToolCallFailed(Known/response_rejected)`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `SubmitToolResponse` | Waiting(ExternalResponse);`ToolCallAnswered{ResponseDigest}`。Evolve 后若全部 call 已 terminal,则关闭 ToolStep | +| `CancelRun` | active;先把仍 Executing 的 tool call 记 `ToolCallFailed(Unknown)`,随后 `RunEnded(stopped/cancelled)`,并在 `RunStoppedEnd` / `RunResult` 上列出 `UncertainCalls` 与 `UncertainModel`。Waiting call 无论有无 Executing sibling 都不记 Failed;`RunEnded` 把 `Current` 置空。若这批 Unknown 使全部 call 进入终态,折叠会走 ToolStep 关闭路径并写入 `LastToolStep`;仍有 Waiting 或 Pending 时不走关闭路径,`LastToolStep` 保持原值。 | + +没有独立的 `ToolStepClosed` fact。最后一个 ToolCall 进入 Completed 或 Failed 时,`Evolve` 在折叠该 fact 后若全部 call 已 terminal,则把 Current 设为 `Open` 并写入 `LastToolStep`;下一次 `PlanningHint.SourceStep` 取自 `LastToolStep.RefValue.ID`。Cancel 的 Unknown fact 同样走这条关闭规则;`RunEnded` 再把 Current 置空。 + +**RUN-MCH-3** `Protocol.Decide(state, command)` 执行全部验证与 derived consequence,一次返回该组的完整 ordered fact group;验证成功后返回完整 facts。`Protocol.Evolve(state, fact)` 机械折叠 fact,依赖 fact 携带的完整执行状态数据。accepted facts 必须 self-contained;若该组 terminalize,`RunEnded` 必须是 Decide 输出的最后一个 fact。 + +启动 command 的最小公共形状为: + +```go +type StartModelExecution struct { + StepID StepID + Claim ExecutionClaim +} +type StartToolCall struct { + StepID StepID + CallID CallID + Claim ExecutionClaim +} +type RecoverModelExecution struct { + StepID StepID + Claim ExecutionClaim +} +``` + +一次执行 attempt 的全部 command identity 都从其 `Claim` 派生:start、owner settlement、model recovery 的 CommandID 分别按上表计算,Commit 对 start 强制校验该派生。因此 Loop 的 worker 只需在内存中保留 `Claim` 一个值直到 settlement 完成:提交返回非 sentinel 错误时,以同一 Claim 重放得到同一 CommandID,Writer 对精确重放返回 AlreadyApplied(RUN-LOP-5)。Claim 不需要持久化:进程崩溃后由接管者按 RUN-CMT-7 处置全部 Executing 目标,不依赖前一进程的 Claim。 + +`Next(state)` 最多返回一个 transient `Effect`: + +| state | effect | +|---|---| +| terminal | 返回 `ErrRunTerminal`,没有 effect | +| `Open` | `NeedModelRequest{PlanningHint}` | +| Model Prepared 且 `PendingInputs` 非空 | `WithdrawPrepared` | +| Model Prepared | `StartModelCall` | +| Model Executing | `Idle` | +| ToolStep 有 Pending calls | `StartToolCalls` | +| ToolStep 无 Pending、仍有 Waiting 或 Executing | `Idle` | + +Waiting call 上的 `ResponseRequest` 由 `WaitingCalls(state)` 读取。Executing call 由 `ExecutingCalls(state)` 读取。`NeedsRecovery(state)` 在 Model Executing 或 ToolStep 无 Pending 且仍有 Executing 时为 true。这些查询不是 Effect。 + +**RUN-MCH-4** Effect 由调用方每次 Load 后重新派生。`AcceptInput` 在任意非终态入队,Decide 不因 Run 正在执行而拒绝它;`PendingInputs` 只在 `Open` 的 Prepare 中被消费。`PrepareModelRequest.InputIDs` 必须与当前 PendingInputs 等长、同顺序、逐项相同;prepare 接受后一次消费全部 pending input。ToolStep 的 Waiting call 禁止 Start,同一 step 中的 Pending call 仍可执行。没有可执行 Start 时 `Next` 返回 `Idle`。Application 从投影读取 `WaitingCalls` 并提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse`。Executing 目标在当前 owner 进程内由其 worker 结算;owner 崩溃后由接管者按 `NeedsRecovery` 一次性处置(RUN-CMT-7)。 + +## 5. Runtime、投影与 Commit + +```go +type Runtime interface { + Load(context.Context, session.SessionID, RunID) (RuntimeSnapshot, error) + Commit(context.Context, session.SessionID, CommitRequest) (CommitResult, error) + Record(context.Context, session.SessionID, RunID) (RunRecord, error) + FrozenRequest(context.Context, Digest) (ModelRequest, error) + // RecoverInterrupted 是接管处置:对该 Session 投影中全部 Executing 目标各提交一个 recovery command, + // 返回提交数。宿主在 OpenWriter 之后、驱动任何 Run 之前调用一次(RUN-CMT-7)。 + RecoverInterrupted(context.Context, session.SessionID) (int, error) +} +// RunPosition 是该 RunID 最后一条 twilight/run/ 事件的 Seq;只有这个 Run 自己的事件会移动它。 +type RunPosition = session.Seq +type RuntimeSnapshot struct { + State MachineState // detached in-process view + Position RunPosition + Head session.Head // 读取时的 Session head + SchemaVersion uint16 // created.SchemaVersion +} + +// ModuleEvent 是其他模块的 typed event,由 agent/session/run 经 Registry 编码。 +type ModuleEvent struct { + Type session.EventType + Value any +} +// Companion 把一组 Run facts 与 command 携带的 transient 内容映射为 +// 其他模块的事件(对话内容、Turn completed)。实现由 agent/turn 提供(TRN-CMP)。 +type CompanionRequest struct { + Session session.SessionID + Owner OwnerID + RunID RunID + Command AgentCommand + Facts []Fact + State MachineState // Evolve 后 + RecordedAtUnixMilli int64 +} +type Companion interface { + Version() string + Map(CompanionRequest) ([]ModuleEvent, error) +} +type Protocol struct { + // ProtocolFor 一次绑定该 SchemaVersion 的函数。方法不再接受 version 参数。 +} +func ProtocolFor(schemaVersion uint16) (Protocol, error) +func (RuntimeSnapshot) Protocol() (Protocol, error) +func (Protocol) Version() uint16 +func (Protocol) DigestRequest(ModelRequest) (Digest, error) +func (Protocol) DigestToolDefinition(ToolDefinition) (Digest, error) +func (Protocol) DigestToolSpecs([]ToolSpec) (Digest, error) +func (Protocol) DigestModelStepBinding(ModelRef, Digest, Digest) (Digest, error) +func (Protocol) DigestModelResult(ModelResult) (Digest, error) +func (Protocol) DigestToolOutput(CanonicalJSON) (Digest, error) +func (Protocol) DigestToolResponseDecision(ResponseKind, ResponseDecision, string) (Digest, error) +func (Protocol) EncodeFact(typ string, fact Fact) (jsonstable.Value, error) // 不含 v;Registry 加入 +func (Protocol) DecodeFact(typ string, wire jsonstable.Value) (Fact, error) +func (Protocol) Decide(MachineState, AgentCommand) ([]Fact, error) +func (Protocol) Evolve(MachineState, Fact) (MachineState, error) +func (Protocol) BuildEnvelope(session.SessionID, RunID, CommandID, AgentCommand) (CommandEnvelope, error) +func (Protocol) BuildCreateGroup(NewRun, []AgentInput) ([]Fact, error) +func (Protocol) EncodeMachineState(*MachineState) (jsonstable.Value, error) +func (Protocol) DecodeMachineState(jsonstable.Value) (MachineState, error) +func ProtocolV1() Protocol + +type CommitRequest struct { + Base RunPosition // Load 时的 Position;PrepareModelRequest 为 hard CAS,其他 command 可为零值 + Command CommandEnvelope + Attach []ModuleEvent // 调用方附加事件,追加在 companion 之后;例如 Coordinator.Stop 的 twilight/turn/failed +} +type CommitResult struct { + Status CommitStatus // CommitAccepted | CommitAlreadyApplied + Snapshot RuntimeSnapshot + Events []session.SessionEvent // 本次 command 的完整组:run facts、companion、Attach +} +``` + +Runtime 由组装代码以 `extension.Writers`(EXT-WRT-6)、`FrozenValueStore`、`Companion` 与 `SnapshotPolicy` 构造;它按 SessionID 取得该 Session 的 `Writer`,全部读写经该 Writer。 + +**RUN-CMT-1** Runtime 按 `(SessionID, RunID)` 寻址。Run 由 Coordinator 的 Start 组创建(TRN-STR-2),Runtime 没有 `Create`。`ErrRunNotFound` 只用于该 Session 中不存在的 RunID。已终结的 Run 不在 `twilight/run/machine` 投影中(RUN-CMT-2),`Load` 对它以 `Types=[twilight/run/]` 过滤 `Read`、按 RunID 筛出全部事实后 FoldRun,返回终态 snapshot;`Commit` 对它返回 `ErrRunTerminal`。这条路径是兜底:正常流程中 Loop 从结算返回的 snapshot 读到终态(第 7 节),Coordinator 从 turn surface 的 `AttemptView` 取终态与 SchemaVersion(TRN-PRJ-1),都不依赖它。 + +**RUN-CMT-2** 投影 `twilight/run/machine` 消费全部 `twilight/run/` 事件,其他模块的事件按 EXT-PRJ-2 跳过,状态为: + +```go +type MachineProjection struct { + Active map[RunID]MachineState // 非终态 Run + Positions map[RunID]RunPosition // 非终态 Run 的最后事件位置 + Ended map[RunID]struct{} // 已终结的 RunID,只用于拒绝第二条 created +} +``` + +终态 Run 在 `RunEnded` 折叠后从 `Active` 与 `Positions` 移除,只在 `Ended` 保留 RunID 用于拒绝同一 RunID 的第二条 `created`(RUN-NEW-1);终态结果由 `Record` 与 turn surface 提供,投影大小与活动 Run 数成正比,加上已终结 RunID 的集合。`Load` 经 `Writer.Projections()` 读取 Writer 内存中的投影(EXT-PRJ-4);独立进程的观察者经 `extension.NewProjectionReader` 从 Store 读取,投影缓存(EXT-PRJ-3)是可丢弃的派生数据,写入策略由 `agent/session/run` 的 `SnapshotPolicy` 决定,默认在 Run 的 `Current` 回到 `Open` 或 Run 终结时写入,并可按组计数补充。`Record` 以 `Types=[twilight/run/]` 过滤 `Read` 读取该 RunID 的全部事件(SES-REP-2),FoldRun 重建;该 Run 仍在投影中时与投影状态比对,divergence 必须失败。 + +**RUN-CMT-3** Commit 经 `extension.Writer.Commit` 在该 Session 的 Writer 互斥区内完成(EXT-WRT-1)。所有 Runtime implementation 在 fn 内调用同一个 pure `EvaluateCommit`,顺序固定为: + +```text +writer.Commit(func(view): + 1 validate envelope SessionID/RunID/schema/type + 2 view.LookupCommit(CommitID = CommandID) + 3 found -> fn 返回 nil(Writer 记 Noop);Runtime 以查到的行与当前投影构造 CommitAlreadyApplied + 4 derived CommandID check + 5 state = view.Projection(twilight/run/machine).Active[RunID] + 不在 Active:在 Ended 或过滤 Read 到该 RunID 的事件 -> ErrRunTerminal;否则 ErrRunNotFound + schema 不等于 created.SchemaVersion -> 不可重试错误 + 6 validate hard CAS(prepare 的 Base == Positions[RunID])/ target state + 7 facts = Protocol.Decide(state, command) exactly once + 8 Protocol.Evolve in order;facts -> ModuleEvent(Type twilight/run/,v = SchemaVersion) + 9 companion = Companion.Map(...);校验 SourceDigest(TRN-MAP-3);追加 request.Attach(不得为 twilight/run/ 事件) + 10 return SemanticGroup{CommitID: CommandID, Events: run ++ companion ++ attach} +) +// Writer 完成 codec、Binding admission、claim(先于 Append)、Append 与投影折叠(EXT-WRT-1/3)。 +// Runtime 在 Commit 返回后按 SnapshotPolicy 写投影缓存;缓存写入失败不影响 commit 结果。 +``` + +FrozenValueStore 的 `Put` 幂等且内容寻址,在进入 Writer 之前完成;Commit 失败时留下的本体无害,可由保留策略回收。 + +**RUN-CMT-4** `PrepareModelRequest` 是 hard-CAS command:`Base` 必须等于投影记录的该 Run 的 `Position`。这是有意选择:同一 Session 内其他模块的写入(用户提交新输入、summary、checkpoint、其他 Turn 的事件)不移动 Position,因此不使 Prepare 失效;Plan 与 Prepare 之间发生的 chatlog 写入不会被本次请求包含,新鲜度由 Application 经 `PlanningToken` 与 Planner 自行负责,Run 不校验 `PlanningToken` 的语义。其他 command 通过当前 target state 做 call-local rebase,`Base` 可为零值或过期值;stale Base 本身不阻止无冲突的 ingress/control/settlement。相同 command 的 replay 判定先于 terminal check,因此 terminal Run 仍能返回原组。 + +**RUN-CMT-5** 幂等键为 Writer 的 `(SessionID, CommitID)` 索引(EXT-WRT-2),CommitID 等于 CommandID,Runtime 不另设幂等索引。同 CommandID 的重放返回 `CommitAlreadyApplied`、当前 snapshot 与原完整组,且不得再次 Decide 或产生外部 effect;command 不持久化,Runtime 不比对重放 command 的内容,同 CommandID 视为同一 command。对于 `StartModelExecution` 和 `StartToolCall`,claim 是 CommandID 的 preimage,不同 claim 即不同 command:其 start 按当前 target state 评估,target 已是 Executing 时返回 `ErrStaleRuntime`。 + +**RUN-CMT-6** 执行授权与所有权失效。Runtime 不签发 grant,也不校验按目标的执行授权:Session 所有权(SES-OWN-1)即执行所有权,同一进程内同一 Run 至多一个 Loop 在驱动(第 7 节的 driver slot),Executing 目标的 settlement 只可能来自该 Loop 的 worker 或接管处置。跨进程的迟到写入由 kernel 的 Epoch fencing 拒绝(SES-OWN-2):Writer 返回 `ErrOwnershipLost` 时 Runtime 原样返回该错误,Loop 必须取消全部 worker、放弃 settlement 并以该错误返回(RUN-LOP-5);Coordinator 同样放弃该 Session(TRN-REC-2)。 + +**RUN-CMT-7** 接管处置。新 owner 取得 Writer 后,在驱动任何 Run 之前调用一次 `RecoverInterrupted`:对投影中每个 Executing 的 ModelStep 提交 `RecoverModelExecution{Claim: TakeoverClaim}`,对每个 Executing 的 tool call 提交 `SubmitToolFailure{Outcome: Unknown}`(CommandID 以 TakeoverClaim 派生,第 2 节 identity 表);Pending call 不处置(start barrier 证明它从未运行,由下一次 Loop 启动);Waiting call 不处置。每个处置是一次普通 Commit,companion 在同组写入 status=`unknown` 的 `tool_result`(TRN-CMP-2);Run 保持 Active,同一 RunID 继续。`TakeoverClaim` 由 Writer 的 Epoch 派生,因此同一 owner 重复调用幂等(同 CommandID 得到 AlreadyApplied),不同 owner 的处置各自成为新 command。宿主在 `RecoverInterrupted` 返回后才 Resume 各 Turn(TRN-REC-1)。 + +**RUN-CMT-8** 每个 Run 的协议版本是 `created.SchemaVersion`,创建时冻结。`RuntimeSnapshot.SchemaVersion` 等于该值;`ProtocolFor(schemaVersion)` 返回绑定该版本 digest/codec/Decide/Evolve 的 `Protocol`。`EvaluateCommit` 接受 command 当且仅当 `CommandEnvelope.SchemaVersion` 等于该 Run 的版本。新 Run 由 `NewRun.SchemaVersion` 决定版本;同一 Session 内不同 Run 可以使用不同版本;v1 Run 的 replay 必须继续使用 `ProtocolV1()`。Run 的版本与 Session kernel 的 `ProtocolVersion` 无关(SES-VER-1)。 + +### 5.1 不进入 stream 的数据 + +`ExecutionClaim` 只存在于持有它的 worker 内存中;投影缓存是可丢弃的派生数据(EXT-PRJ-3);`FrozenValueStore` 是内容寻址旁存。三者都不是 authority,丢失后的后果分别为:该 attempt 无法在本进程内重放(由 RUN-LOP-5 的一次重试之外的路径处理,或随进程崩溃由接管处置覆盖)、投影从 stream 重折、Executing/Prepared step 的重发失败为不可重试错误(Application 决定 Retry)。本协议没有控制面 KV、lease、grant 或 durable ClaimStore;曾有过这些机制及删除它们的决定见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节。 + +## 6. Loop ports 与 policy + +```go +// package agent/run +type PlanningHint struct { + Session session.SessionID + Owner OwnerID + RunID RunID + SourceStep StepID + Inputs []AgentInput +} +// package agent/run/loop +type RequestPlanner interface { + Plan(context.Context, run.PlanningHint) (RequestPlan, error) +} +type RequestPlan struct { + Model run.ModelRef + Request sdk.Request + InputIDs []run.InputID + PlanningToken run.PlanningToken + Tools []run.ToolSpec // 与 Request.Tools 一一对应;DefinitionDigest 由 Loop 校验 +} +type ModelCatalog interface { ResolveModel(run.ModelRef) (ModelInvoker, error) } +type ModelInvoker interface { Generate(context.Context, sdk.Request) (sdk.ModelResult, error) } +type StreamingModelInvoker interface { Stream(context.Context, sdk.Request) (sdk.ModelStream, error) } +type ToolCatalog interface { ResolveTool(run.ToolRef) (ExecutableTool, error) } +type ExecutableTool interface { + Ref() run.ToolRef + Definition() sdk.ToolDefinition + ResponsePolicy() run.ResponsePolicy + ValidateArguments(run.CanonicalJSON) error + Execute(context.Context, ToolExecutionRequest) ToolExecutionOutcome +} +``` + +`ToolExecutionOutcome` 是 sealed interface:`ToolExecutionSucceeded`、`ToolExecutionFailed`(明确未完成)或 `ToolExecutionUnknown`(可能已发生)。`ValidateArguments` 在 start barrier 前运行,并保持无外部 effect。 + +```go +type ToolExecutionMode string + +const ( + ToolExecutionParallel ToolExecutionMode = "parallel" + ToolExecutionSequential ToolExecutionMode = "sequential" +) + +type ExecutionPolicy struct { + ToolExecution ToolExecutionMode + MaxParallel int + OnMalformedModelResult func(run.ModelStep, run.StepFailure) run.ModelRejectDisposition +} +type LoopResult struct { + Disposition LoopDisposition // LoopWaiting | LoopFinished + Reason WaitReason // 仅 ExecutionRecovery 时为 execution_recovery;否则为空 + ExecutionRecovery bool + Result *run.RunResult +} +func New(models ModelCatalog, tools ToolCatalog, planner RequestPlanner, policy ExecutionPolicy, streaming bool) (*Loop, error) +func (*Loop) Run(context.Context, run.Runtime, session.SessionID, run.RunID, EventSink) (LoopResult, error) +``` + +**RUN-LOP-1** `ExecutionPolicy` 是 Loop 的本地执行策略。`ToolExecution` 与 `MaxParallel` 在 `SubmitModelResult` 时写入 `ToolStepOpened.Scheduling` 并冻结在该 ToolStep 上;后续 Loop 必须按冻结值调度,不得改用当时进程的 ExecutionPolicy。未指定 `ToolExecution` 时冻结为 `parallel`,`MaxParallel` 零值表示当前 Start 批次全部 Pending call 可并行。空 Mode 按 parallel 解释,不得在 normalize 时填入默认字符串。nil handler 时结构错误的模型结果选择 `ModelRejectFailRun`;重试由 handler 明确返回 `ModelRejectRetry`。`streaming` 表示是否请求可用的流式模型端口;两种模式都产生同一完整 `sdk.ModelResult`。Loop 没有租约续期与 durable ClaimStore:执行所有权由 Session Writer 承担(RUN-CMT-6)。 + +**RUN-LOP-7** `ModelRef` 是冻结请求中的执行身份。`ModelCatalog.ResolveModel` 在同一 Run 生命周期内必须把同一 `ModelRef` 解析为等价的执行语义。provider 绑定不进入 frozen request,因此 Catalog 不得把同一 ref 改绑到不同实现。 + +`LoopResult` 的语义固定为:`LoopWaiting` 时 `Result` 为 nil,表示没有可执行 effect、Run 仍为 active。`ExecutionRecovery` 等于 `NeedsRecovery(state)`。该值为 true 表示存在本进程未持有 Claim 的 Executing 目标(只在崩溃后、接管处置之前出现),`Reason` 为 `execution_recovery`;否则 `Reason` 为空。Waiting call 不进入 `LoopResult`;Application 通过投影的 `WaitingCalls` 读取。`LoopFinished` 时 `Result` 非 nil,并等于 terminal Run 的 `RunResult`。 + +`RequestPlanner` 从 `PlanningHint` 接收 Run 边界事实;它从 Session 的 chatlog fold 读取对话内容(上一步的 assistant 与 tool_result 已随 Run fact 同组提交),并使用自己注入的 memory、attachments 与 product policy 组装 `sdk.Request`。Runtime 验证并冻结 planner 返回的 request,Planner 管理 application context。 + +## 7. Loop execution + +```text +Loop.Run(ctx, runtime, sessionID, runID, sink): + repeat: + snapshot = Runtime.Load(sessionID, runID) + if terminal: emit observational run_finished; return Finished(snapshot.Result) + effect = run.Next(snapshot.State) + dispatch effect + // 模型结算(无 tool call 的 SubmitModelResult、SubmitModelFailure、FailRun 的 RejectModelResult) + // 可能终结 Run;此时 CommitResult.Snapshot 已是终态,Loop 直接 emit run_finished 并 + // return Finished(snapshot.Result),不再 Load。工具结算不会终结 Run。 +``` + +每个 `Loop` 实例为每个 `(SessionID, RunID)` 分配一个本地 driver slot。同一实例对同一 Run 的并发 `Run` 调用返回 `ErrRunAlreadyRunning`;不同 Run 可以并行驱动。宿主必须保证一个 Session 在一个进程内只有一个 Loop 实例驱动它的 Run(与 `Writer` 一一对应)。 + +**RUN-LOP-2** `NeedModelRequest` 调用 Planner,冻结 sdk.Request,验证 model、ordered InputIDs 与 ToolSpecs,计算 request/tools/binding digests 和 derived CommandID/StepID,再提交 Prepare(command 携带本体)。prepare stale 后重新 Load;同 Position 的内容拒绝不得 livelock 重试。业务停止统一使用 `CancelRun`。 + +**RUN-LOP-8** `WithdrawPrepared` 时 Loop 提交 `WithdrawPreparedStep{StepID}`,随后重新 Load;被放弃请求的本体在 FrozenValueStore 中可立即释放。Loop 不为输入做任何其他事:Executing 与 ToolStep 期间到达的输入留在 `PendingInputs`,由随后 `Open` 的 `NeedModelRequest` 经 `PlanningHint.Inputs` 交给 Planner。 + +**RUN-LOP-3** `StartModelCall` 先 Commit start barrier;`CommitAccepted`,或以同一 Claim 重试得到的 `CommitAlreadyApplied`(RUN-LOP-5 的一次重放),表示本 Loop 拥有该 execution。worker 在内存中保留该 attempt 的 Claim 直到完成 settlement,其余 identity 按需派生。调用使用 `Runtime.FrozenRequest(snapshot.State.Current.RequestDigest)` 取回的本体的 detached SDK materialization;本体缺失为不可重试错误,交由 Application 处理。streaming 与 non-streaming 必须产生同一种完整 `sdk.ModelResult`;delta 只发 EventSink。`ModelCatalog.ResolveModel` 失败或返回 nil 时提交 `RecoverModelExecution` 并返回错误,不得把 Run 记为 `provider_failure`:尚未发生模型调用。provider 调用失败提交 `SubmitModelFailure`;ctx cancellation 提交 `RecoverModelExecution`;结构、binding 或 freeze 失败提交 `RejectModelResult`,并由调用方显式选择 retry 或 fail-run。成功结果只提交一次 `SubmitModelResult`。 + +**RUN-LOP-4** Tool execution 先按 frozen binding resolve tool,并验证 Ref、definition digest、response policy 和 arguments。lookup/definition/argument failure 在 Pending 状态提交 `SubmitToolFailure(Known)`,不得跨越 start barrier。通过验证后逐 call 提交 `StartToolCall`;只有 start 被接受的 worker 可执行。冻结的 `ToolStep.Scheduling` 决定 `parallel` 或 `sequential` 以及 `MaxParallel`;不得改用 Loop 进程当前的 ExecutionPolicy。每个结果以自己的 Claim 派生 CommandID 提交。同一 ToolStep 中 DirectExecution 的 Pending call,在外层 ctx 未取消时于本次 `Run` 内按冻结 Scheduling 分批 Start 并结算;ctx 已取消时停止再 Start,只结算已 Start 的 call。`Next` 返回 `Idle` 时 Loop 返回 `LoopWaiting`,并用 `NeedsRecovery(state)` 设置 `ExecutionRecovery`。Loop 不解释 Waiting call,也不携带 `ResponseRequest`。Application 从投影读取 `WaitingCalls`,提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 之后再次 `Run`。tool panic 或 effect 状态无法确定的错误转为对该 call 的 Unknown,并提交 `SubmitToolFailure(Unknown)`。该 settlement 不取消同批 sibling workers,也不结束 Run。`CancelRun` 先把仍 Executing 的 call 记为 `ToolCallFailed(Unknown)`,再 `RunEnded(stopped/cancelled)`,并把这些 CallID 与仍 Executing 的 ModelStep 写入 `RunStoppedEnd` / `RunResult` 的 `UncertainCalls`、`UncertainModel`。Waiting call 无论有无 Executing sibling 都不记 Failed。已接受 start 的 worker 必须在收到外层取消后返回并尝试 settlement;settlement 使用独立 control context。lookup/definition/argument failure 只允许发生在 Pending。 + +**RUN-LOP-5** model 与 tool worker 都接收外层 ctx;Loop 对已接受 effect 使用独立 control context 完成 known/unknown outcome settlement。Application 的业务停止顺序为先 Commit `CancelRun`,再取消 Loop ctx。非 sentinel Commit error 以同 CommandID 重放一次;仍未知时返回错误,由后续 Load/Record 查询 authority。stale/terminal/conflict 触发 reload/drop,旧 external effect 保持单次执行尝试。`ErrOwnershipLost` 是终止性错误:Loop 取消全部 worker 的 ctx,不再提交任何 settlement(提交也会被 kernel 拒绝),以该错误返回;已发生的外部 effect 由接管者按 RUN-CMT-7 记为 Unknown。工具实现配合 context 返回;永久阻塞由 application 处理。 + +Waiting call 的批准与外部结果由 Application 提交。Loop 不生成、不返回、不解释 `ResponseRequest`。Application 以投影中的 stable ResponseID、derived CommandID 与 payload/decision digest 提交 `ApproveToolCall`、`RejectToolCall` 或 `SubmitToolResponse`;随后再次运行 Loop。 + +## 8. EventSink 与边界 + +```go +type EventSink interface { Emit(context.Context, Event) error } +type Event struct { + Session session.SessionID + RunID run.RunID + StepID run.StepID + CallID run.CallID + Sequence uint64 + Kind EventKind + Durability EventDurability + Payload json.RawMessage + Committed []session.SessionEvent // EventAgentCommitted 携带本次 command 的完整组 +} +``` + +`Sequence` 仅用于同一临时观察流内的顺序(例如 ToolProgress),从 1 开始;committed observation 的权威顺序由 Session `Seq` 表达,未提供临时序号时保持 0。 + +**RUN-LOP-6** EventSink 提供 realtime observation,Loop 通过序列化调用向 sink 发送事件。`EventAgentCommitted` 携带 accepted 组;text/reasoning delta、tool progress、tool lifecycle 与 run-finished observation 可丢失、重复或断流。sink failure 保持 Commit 结果;恢复与审计读取 Session stream,EventSink gap 通过 stream 对账。 + +`AcceptInput` 在任意非终态提交,`PendingInputs` 就是回合中途输入的队列;Loop 不解释 queue 或 steer:`Open` 时立刻 `NeedModelRequest`,Prepare 一次消费全部 pending input。Application 负责 admission;Turn 创建、attempt、中途投递、结算与 companion 映射由 [agent-turn.md](agent-turn.md) 定义。 + +## 9. compatibility 与 conformance + +**RUN-CMP-1** 当前 pre-release schema v1 的 command/fact discriminator、wire fields、canonical digest、derived ID 和 `ProtocolV1().Evolve` 由 golden fixtures 保护;发布前有意修改协议时必须同步更新 fixture。v1 发布后,新增 variant、字段或折叠语义必须进入新 `SchemaVersion`,Registry 继续 decode/fold 全部已发布版本;同一 Run 的 writer 不得混写不同版本。Run 版本演进不触发 Session kernel 版本变化。 + +**RUN-CMP-2** Runtime conformance 只断言 Run 模块自己的语义;组原子性、digest chain、所有权与 Epoch fencing、幂等索引、投影缓存复用由 Session kernel 与 Module Framework 的 conformance 覆盖(SES 第 7 节、EXT 第 7 节),本清单以引用代替重复。conformance 以 `session.Store` 为参数(`agent/session/run/runtimetest`),Memory 与文件 adapter 跑同一套。必须覆盖: + +- 建立与寻址:Start 组建立 Run;同一 RunID 第二条 `created` 使投影 fold 失败;未知 RunID 的 Load、Commit、Record 返回 `ErrRunNotFound`;已终结 Run 的 Load 返回终态 snapshot 且与 Record 一致,Commit 返回 `ErrRunTerminal`(RUN-CMT-1);`CommandEnvelope.SchemaVersion` 与 `created.SchemaVersion` 不一致的 command 被拒绝且不可重试; +- 重放与 Base:同 CommandID 返回 `CommitAlreadyApplied` 与原组且不再 Decide;Run 已终结后对已接受 command 的重放仍返回 AlreadyApplied,新 command 返回 `ErrRunTerminal`;prepare 的 Base 不等于该 Run 的 Position 时返回 `ErrStaleRuntime`;非 Prepare command 接受零值或过期的 Base(call-local rebase); +- 输入入队:`AcceptInput` 在 Open、Model Prepared、Model Executing、ToolStep 都被接受;Prepared 期间入队后 `Next` 返回 `WithdrawPrepared`,Withdraw 后重规划的 Prepare 包含该输入;Executing 期间入队的输入在无 tool call 的 `SubmitModelResult` 后使 Run 回到 Open 而不结束; +- start 与 claim:同 claim 的 start 重放返回 AlreadyApplied;不同 claim 的 start 在 target 已是 Executing 时返回 `ErrStaleRuntime`;同一 attempt 的 settlement 以其 Claim 派生 CommandID,重放返回 AlreadyApplied; +- 组的组成:一 command 一组,同一 CommitID;组内 run 事实在 companion 与 Attach 之前;companion 中非空 `SourceDigest` 等于同组 fact 记录的 ResultDigest / OutputDigest / ResponseDigest;Attach 携带 `twilight/run/` 事件被拒绝;companion 与 Attach 中的 ReferencePart 经 admission,未注册 Binding 使 Commit 失败且无写入,合法 Binding 在 Append 之前建立 Active claim(EXT-WRT-3); +- 结算返回值:`CommitResult.Snapshot` 是 Evolve 后状态;终结 Run 的结算其 `Snapshot.Status` 为终态且 `Result` 非空,与 Record 一致; +- Prepare hard CAS 只对该 Run 自己的事件敏感:同一 Session 内 chatlog、turn 或其他 Run 的写入不改变该 Run 的 Position,也不使 Prepare 失效; +- 投影:`SnapshotPolicy` 在 Run 回到 Open 或终结时写入投影缓存;终态 Run 不出现在 `Active`,其 RunID 在 `Ended`;Record 对活动 Run 的 fold 与投影一致;非法 fact 序列使 FoldRun 报错(篡改与缺口的检测属于 SES-REP-1); +- 隔离:同一 Session 内多 Run 互不影响 Position 与 Record;chatlog 与 turn 事件不影响 Run fold。不同 SchemaVersion 的 Run 共存在第二个 SchemaVersion 发布后启用; +- 接管处置:关闭 Writer 后以新 Writer 打开(Epoch 加一)并调用 `RecoverInterrupted`:Executing model 回到 Prepared 且 `FrozenRequest` 返回同一 RequestDigest 的请求;Executing tool 记 Unknown 且 companion 在同组写入 status=`unknown` 的 `tool_result`,同 step 的 Pending 与 Waiting call 不受影响;Run 保持 Active;同一 Epoch 重复调用返回 0 且无新写入;没有 Executing 目标时返回 0; +- 所有权失效:旧 Writer 上的 Runtime 在被接管后 Commit 返回 `ErrOwnershipLost` 且 stream 无新行(fencing 由 SES-OWN-2 保证,本层观察结果); +- FrozenValueStore:`Put` 幂等;未知 digest 的 `FrozenRequest` 返回 `ErrFrozenValueMissing`;step 终结后删除本体不影响 Record; +- MachineState codec:每个 Current variant 与终态 round-trip、拒绝 unknown field / 非法判别式 / trailing data(`agent/run` 单元测试)。 + +Loop conformance 必须覆盖: + +- 单模型完成、tool round trip、approval/external response wait/resume; +- known failure 继续、Unknown 继续、tool panic、aliased ToolRef 与 validation; +- parallel/sequential 按冻结 `ToolStep.Scheduling` 调度,不得改用当时 ExecutionPolicy; +- `ModelCatalog.ResolveModel` 失败或 nil 时恢复 ModelStep、Run 保持 active; +- ctx cancellation、model recovery 后重发同一 RequestDigest、explicit malformed-result disposition; +- Cancel 将 Executing tool/model 投影到 `UncertainCalls` / `UncertainModel`;ExternalResponse reject 为 `response_rejected`; +- streaming delta 与 nil result、EventSink committed observation 携带完整组; +- 非 sentinel commit error 的一次重放、prepare no-progress rejection 与无 livelock; +- 模型结算终结 Run 时 Loop 不再 Load,返回 `LoopFinished` 且 `Result` 等于 Record 的终态; +- Writer 返回 `ErrOwnershipLost` 时 Loop 取消 worker、不再提交 settlement、以该错误返回;随后新 owner 的 `RecoverInterrupted` 把该 Executing 目标记为 Unknown 或回到 Prepared。 + +package 迁移、实施阶段与未完成 adapter 工作记录在 [agent-runtime-refactor.md](agent-runtime-refactor.md),本协议 authority 以本文为准。 diff --git a/docs/design/agent-runtime-refactor.md b/docs/design/agent-runtime-refactor.md new file mode 100644 index 0000000..a8a8707 --- /dev/null +++ b/docs/design/agent-runtime-refactor.md @@ -0,0 +1,359 @@ +# Twilight Agent Runtime 重构记录 + +状态:迁移记录,非协议规范 + +当前 Run/Loop 协议的唯一 authority 是 [agent-run.md](agent-run.md)。本文只保留重构背景、已接受的 package 决策、完成状态与后续迁移工作;实现与本文冲突时,以各领域正式规范为准。 + +正式规范: + +| 领域 | authority | +|---|---| +| Run Machine、Runtime、Loop | [agent-run.md](agent-run.md) | +| Session ES kernel | [agent-session.md](agent-session.md)(草案) | +| Artifact Core | [agent-artifact.md](agent-artifact.md)(草案) | +| Session Module Framework | [agent-session-extension.md](agent-session-extension.md)(草案) | +| Chatlog ontology/projection | [agent-session-chatlog.md](agent-session-chatlog.md)(草案) | +| Turn→Run coordination/materialization | [agent-turn.md](agent-turn.md)(草案) | +| 参考组装(Agent/Profile / Planner / Input / Session 宿主) | [agent-reference-assembly.md](agent-reference-assembly.md)(草案) | + +## 1. 背景 + +重构前的 agent execution 代码混合了 SDK transport、Run state、Loop、history、queue 与 application policy,导致: + +- 单次模型调用和多步 Agent execution 边界不清; +- state mutation、event persistence 和恢复路径缺少统一 authority; +- local 与 durable execution 使用不同抽象; +- queue、Session history 和 Run progress 容易形成多份长期事实; +- package 边界无法表达不同变化周期。 + +本次重构把 Agent Core 收敛为相互独立的 Run、Session、Artifact、Session Module、Chatlog 和 Turn 协议,并保留 `sdk` 作为单次 provider transport boundary。 + +## 2. 已接受的架构决策 + +### 2.1 authority + +```text +Session stream 唯一 authority:twilight/turn、twilight/chatlog、twilight/run 事件同在一条 stream,一行一个 event +Session 所有权 一个 Session 同一时刻一个 Writer 进程;Epoch fencing 拒绝旧写者 +extension.Writer 进程内唯一写入口:串行、幂等索引、admission、claim、投影 +MachineState Run 的语义状态投影(twilight/run/machine),投影缓存为可丢弃缓存 +Runtime Run command 的提交入口:Writer 内 Decide、Evolve、companion,一次 Append +FrozenValueStore 内容寻址旁存:模型请求本体(含工具定义) +``` + +2026-09-04 之前的设计为两条 ES(Run 独立的 `RunHeader + TransitionRecord[]`,Turn 把 Run 事实 materialize 到 Session)。该设计已被第 6 节记录的决定取代;第 7 节记录 2026-09-04 架构审查后的修订(多写者临界区、控制面 KV、lease),第 8 节记录 2026-09-08 的修订(Session 级单写者、扁平事件)。上表为第 8 节之后的形态。 + +### 2.2 package layout + +```text +agent/es shared ES primitives +agent/jsonstable immutable canonical JSON +agent/run Run Machine、frozen values、fact codec、fold、Runtime 与 Companion contract +agent/run/loop in-process model/tool interpreter 与 observation ports +agent/session 追加日志 kernel(Create、Header、Open 所有权与 Epoch、Append 整组、Read);Memory 与文件 adapter +agent/session/extension Session Module Framework:first-party Registry、payload 版本、admission、Writer、ProjectionReader 与缓存 +agent/session/chatlog first-party Message ontology +agent/session/run first-party Run module:EventDefinition、machine projection、Runtime 实现(经 Writer 写入)、接管处置、FrozenValueStore +agent/artifact Ref、Binding、自持久化的两态 RetentionLedger、回收前核对 +agent/turn Turn 生命周期、attempt、CompanionV1 +``` + +文件用于提高同一 package 内的导航性;subpackage 只用于依赖限制和独立变化轴。Loop 因依赖 SDK execution、streaming、并发和工具 ports 而独立成 `agent/run/loop`。Machine、protocol 与 Runtime contract 保持在根 `agent/run`;Runtime 实现与 adapter 在 `agent/session/run`,与 `chatlog` 同级。 + +依赖方向为: + +```text +Application -> agent/turn + agent/run/loop + agent/session/run + adapters +agent/run/loop -> agent/run + agent/session(identity)+ sdk +agent/run -> agent/es + agent/jsonstable + agent/session(identity、Store 类型)+ sdk +agent/session/run -> agent/run + agent/session + agent/session/extension +agent/turn -> agent/run + agent/session + agent/session/extension + agent/session/chatlog +``` + +run、turn、chatlog 三个模块构成一个 agent 领域,耦合方向固定为 turn → run、turn → chatlog;它们保持三个包与三个 EventType 命名空间,因为读侧投影按命名空间筛选事件。可插拔的通用框架(Application Source、Catalog 构建、RuntimeRegistry)没有第二个消费者,推迟到出现时再做(extension 附录 B)。 + +根 `agent/run` 不提供 Loop alias、wrapper 或 façade。 + +### 2.3 boundary decisions + +- `sdk.Request`、`sdk.ModelResult` 和 tool definitions 在 Runtime 前冻结为 run-owned persisted values。 +- Queue、steer/follow-up、fixed-model policy、权限、provider registry 和 MCP lifecycle 属于 Application。 +- Session kernel 保持 payload-opaque、Artifact-free。 +- Chatlog Message 原生支持 first-party Artifact references;`sdk.Message` 只是 materialized provider transport。 +- Turn Coordinator 从 `twilight/turn/surface` 与 `twilight/run/machine` 投影重建,不保存隐藏的长期状态。 +- Run 事实与其对话内容(companion)在同一组(一次 Append)写入;没有 Run→Session materialization、coverage 水位或 outbox。 +- 只有一条写入路径:`extension.Writer`。Run 的 Runtime、Turn 的 Coordinator 都经它写入,companion 与 Attach 事件与其他 producer 一样经 admission;artifact claim 在 Append 之前建立,孤儿由回收前核对释放。 +- 一个 Session 同一时刻一个 Writer 进程(Session 级所有权,Epoch fencing);没有按目标的 lease、grant 或 durable ClaimStore。ExecutionClaim 只在 worker 内存中;投影缓存与 FrozenValueStore 是派生或旁存数据,不进入 stream。 +- 接管者对全部 Executing 目标一次性处置(模型回 Prepared、工具记 Unknown),不逐目标等待或恢复。 +- Run fact 只保存执行状态与内容 digest;请求本体(含工具定义)在 FrozenValueStore,模型输出与工具输出在 chatlog 事件。 +- kernel `ProtocolVersion` 只覆盖行结构与 digest;payload 版本由模块携带(`v` 字段),Run 保留自己的 `SchemaVersion`。 + +## 3. 已完成迁移 + +| 工作 | 状态 | +|---|---| +| SDK single-call boundary 与 run-owned frozen model data | 完成 | +| shared `agent/es` 与 RFC 8785 canonical JSON | 完成 | +| Decide/Evolve/Next Run Machine | 完成 | +| RunHeader、TransitionRecord、wire codec、fold/golden tests | 完成 | +| 第 6.4 节的 `agent/run` 修改(digest-only fact、Owner/Attempt、RunCreated、Withdraw、任意状态入队) | 完成,2026-09-07;golden 重新冻结 | +| per-Run `Store`、`stored_runtime`、`sqlitestore`、`RunHeader`、`TransitionRecord` | 已删除,2026-09-07 | +| Session kernel Memory Store(`agent/session`) | 完成,2026-09-07;conformance 部分实现 | +| `agent/session/extension`(Registry、SemanticAppender、Lease、ProjectionReader) | 完成,2026-09-07;conformance 部分实现 | +| `agent/session/chatlog`(事件、parts codec、Surface、Context) | 完成,2026-09-07;checkpoint 完成,2026-09-09(CHT-EVT-3 转正,宿主策略见 REF-CKP-1/2) | +| `agent/artifact`(Ref、Binding、Memory BindingStore、两态 KV ledger) | 完成,2026-09-07;Resolver/Store/Promoter 未实现 | +| `agent/session/run`(module descriptor、machine 投影、Runtime、RecoverExpired) | 完成,2026-09-07 | +| `agent/run/loop` 绑定 Session(`Run(ctx, runtime, sessionID, runID, sink)`、RunPosition、SessionCommit 观察) | 完成,2026-09-07 | +| `agent/turn` 重写(Coordinator、CompanionV1、surface 投影) | 完成,2026-09-07;旧实现已删除 | +| 参考组装 `agent/ref`(Agent 配置面(原 ExecutionBinding,2026-09-09 改名 Profile)、ContextPlanner、Memory 组装、SessionDriver、Session 宿主、崩溃恢复 example) | 完成,2026-09-07 | +| Runtime conformance(RUN-CMP-2,`agent/session/run/runtimetest`,以 `session.Store` 为参数) | 完成,2026-09-07;对 Memory Store 通过。kernel 与 extension 的 conformance 部分实现 | +| 第 8 节规范修订(session、extension、run、turn、chatlog、artifact、参考组装按单写者与扁平事件改写) | 完成,2026-09-08 | +| 第 8 节代码重构(kernel 收缩、Writer、Runtime 去 lease/grant、接管处置、conformance 重建) | 完成,2026-09-08;`agent/` 下 9 个测试包全部通过,kernel 与 RUN-CMP-2 的 conformance 均以 Store 为参数 | +| 文件 adapter(`agent/session/filestore`)、live 模型接入 | 未开始 | + +2026-09-07 的代码行是第 8 节修订前的形态,已于 2026-09-08 按第 8 节重写。当前正式调用形态为 `agent/ref` 的 Memory 组装:`ref.New` 返回 Store、Registry、Writers、Runtime、Coordinator 与 Agents;宿主对每个 Session 先 `Memory.Open`(取所有权并接管处置)再经 `SessionDriver.Send` 投递输入。Coordinator 只做协议提交与状态读取(Status),驱动编排(解析 profile、调用 driver、组装结果、取消)在宿主层的 `Memory.Drive`(REF-DRV-1)。Loop 不保存 authority state;Runtime 不读取 queue 或 planner context。 + +已决定(2026-09-07):终态 Run 从 `twilight/run/machine` 投影移除后,`Runtime.Load` 对该 Run 按 RunID 过滤 replay 后折叠返回终态,`ErrRunNotFound` 只用于不存在的 RunID(RUN-CMT-1)。该路径为兜底:Loop 在模型结算返回终态 snapshot 时直接结束,不再 Load(RUN 第 7 节);Coordinator 的 Deliver 与 Stop 从 turn surface 的 `AttemptView.SchemaVersion` 构造 envelope,不读 machine 投影(TRN-DLV-2、TRN-STP-1)。曾考虑在投影保留终态 Run 的最小记录,因投影会随历史增长而未采用。 + +已决定(2026-09-07):Runtime 不为 command digest 另设控制面索引;同 CommandID 一律按重放处理,幂等只由 kernel 的 `(SessionID, CommitID)` 承担(RUN-CMT-5)。曾实现过 `twilight/run/command` 索引,用于把同 ID 不同内容判为冲突;该判定只覆盖 approve/reject 撞 ID 与同一 attempt 两次结算两种情形,前者由调用方读投影覆盖,后者属于实现错误,故删除。 + +## 4. 后续实施工作 + +### 4.1 Core reference implementations + +第 6、7 节的全部条目已于 2026-09-07 完成;第 8 节修订后的实施顺序见 8.5。完成后再冻结 kernel `ProtocolVersion` 1 与各模块 payload 版本 1 的 golden fixtures。 + +### 4.2 durable adapters + +- 文件 adapter `agent/session/filestore`:已完成——一个 Session 一个目录(header.json、log.jsonl 一行一个 event、owner.json 承载 epoch 与 owned),每次 Append 一次 fsync,打开时校验摘要链并截掉不完整尾组,接管走 `Takeover`; +- 数据库 adapter(SQLite / PostgreSQL):sessions(header、epoch)、events 两张表,Append 一个事务;只在多会话服务需要时做; +- 收紧 Session authority tables 的 immutable RLS policy; +- 需要远程 Store 或跨存储 claim 时,实现 extension 附录 C 与 artifact 附录的两阶段路径; +- 内容寻址旁存合并:FrozenValueStore 与 artifact 的 `cas` scheme 是同一抽象的两份定义,长期把模型请求本体旁存实现为 authority 固定的 artifact `cas` 存储实例,随第一个真实内容存储(artifact Store/Resolver)一起做。文件后端(`filestore.NewFrozenValues`)已落地,补齐了进程重启后模型中断恢复的重放路径;合并方向不变。 + +### 4.3 Application migration + +- 组合 model/tool registries、permission、queue admission 与 `agent/run/loop` driver; +- 构建 Session Surface/Context projections 与 API; +- 逐步把 `bot_history_messages` 降为兼容 read model; +- 在完整 materialization、terminal settlement 与 retention closure 后执行归档/GC。 + +## 4.3 Run 内部整理(已完成) + +- envelope digest 不匹配从 `ErrCommandConflict` 改为不可重试错误,调用方不再对构造错误 reload 重试(该自校验其后整体删除:envelope 只经 `BuildEnvelope` 构造,不再携带 digest); +- Evolve 对重复 `InputAccepted` 报错而非静默去重; +- `decideSubmitModelResult` 拆为 binding 校验与 ToolStep 派生两步; +- `RunEnded` wire 改为 tagged union,与 Go sealed union 对称; +- `ModelCatalog.ResolveModel` / `ToolCatalog.ResolveTool`,一个类型可同时实现两者; +- `ProtocolV1` 改为函数,不可被重新赋值。 + +## 4.4 sdk.Request 作为冻结类型的评估 + +结论:请求层保留 `run.ModelRequest` 镜像,但把镜像的理由收窄到具体字段;消息层与结果层必须保留镜像。 + +| 层 | sdk 类型中的开放字段 | 能否直接冻结 | +|---|---|---| +| `sdk.Request` 顶层标量与 `Tools`、`ToolChoice`、`StopSequences` | 无 | 能 | +| `sdk.Request.ProviderOptions` | `map[string]json.RawMessage` | 不能:值未 canonical 化,digest 依赖调用方字节 | +| `sdk.Request.ResponseFormat.JSONSchema` | `*jsonschema.Schema`(第三方结构体) | 不能:其 JSON 形状由外部库版本决定,不受本协议冻结 | +| `sdk.Request.Messages[].Content` | `[]MessagePart` 接口,`ToolCallPart.Input any`、`ToolResultPart.Result any`、各 part 的 `ProviderMetadata map[string]any` | 不能 | +| `sdk.ModelResult` | `ToolCalls[].Input any`、`TextProviderMetadata map[string]any`、`Response *ResponseMetadata` 含 `map[string]any` | 不能 | + +因此“让 `sdk.Request` 直接作为冻结请求类型”在当前 sdk 形状下不成立:顶层有两个字段(`ProviderOptions`、`ResponseFormat.JSONSchema`)阻止直接冻结,消息层整体阻止。若要消除请求层镜像,需要先在 sdk 侧完成三项修改:`ProviderOptions` 改为 `map[string]jsonstable.Value`;`ResponseFormat.JSONSchema` 改为 canonical JSON 而非第三方结构体;`MessagePart` 从接口改为闭合的 tagged struct,`Input` / `Result` / `ProviderMetadata` 改为 canonical JSON。这三项都是 sdk 公共 API 变更,影响全部 provider 实现,不在本轮范围内。本轮的处置为:保留镜像,`sdk.Request` 注释中“参与 DigestRequest、无排除字段”的表述已不准确,digest 定义在 `run.ModelRequest` 上;后续若 sdk 完成上述闭合,再删除 `model_data.go` 中请求层的镜像与对应 clone。 + +## 5. 完成标准 + +重构在以下条件全部成立时结束: + +- Memory 与 PostgreSQL Session Store adapters 通过相同 Session 与 Runtime conformance; +- Turn 的 Start、Retry、Stop、Settle 与 Run commit 对 crash、重复提交和 unknown response 可恢复; +- Session/Artifact/Session Module/Chatlog reference implementations 通过各自 conformance; +- production request context 和 UI surface 由 Session projections 提供; +- legacy history 不再承担 canonical write authority; +- Run、Session 与 Artifact 的 durable integrity/recovery paths 有持续 CI 覆盖。 + +## 6. 单一 Session ES 决定(2026-09-04) + +### 6.1 决定 + +Run 从独立的 Event Sourcing 存储改为 first-party Session Module。Run 事实以 `twilight/run/` 事件进入 Session stream;`MachineState` 是投影;per-Run 的 `RunHeader`、`TransitionRecord`、Run `Store` 与 Turn 的 materialization 层删除。 + +### 6.2 依据 + +先前"两条 ES"的两个理由是 run 事实的写入量与生命周期。对一次两步 live run 的记录按字节拆分: + +| 组成 | 占事件字节 | 增长方式 | +|---|---|---| +| `ModelStepPrepared.Request.Messages`(完整历史每步重存) | 9%(两步);随步数平方增长 | 平方 | +| 工具定义,每步重复且在 fact 内存两份 | 13% | 每步 × 工具数 | +| 事件外壳(digest 与 identity) | 42% | 每事件约 365 字节 | +| 其余执行状态与业务载荷 | 35% | 线性 | + +平方项与工具定义重复的原因是存储形状(fact 携带内容本体),与日志条数无关。fact 只留 digest、本体进内容寻址旁存后,run 事实每步约 4 KB、线性,约为同一 run 在 transcript 级事件的 6 倍。生命周期问题由 checkpoint 之下前缀转冷存储解决,不需要删除事件。剩余差距不足以支撑第二条 ES 的成本:跨存储的结算双写、Turn 与 Run 的 linkage 与 coverage 水位、两套提交合同与 conformance。 + +### 6.3 单一 ES 带来的变化 + +- 一个 Run command 恰产生一个 SessionCommit;`CommitID = CommandID`,幂等与 conflict 由 Session kernel 的 `(SessionID, CommitID)` 判定; +- Run 事实与其对话内容在同一 commit:companion 映射由 turn 模块提供,`run.Runtime` 在临界区内调用; +- `RunEnded(completed)` 与 `twilight/turn/completed` 同 commit;`RunEnded(failed)` 不结算 Turn,Turn 进入 `attempt_failed`,由 Retry 或 Settle 决定; +- Turn:Run 为 1:N,`RunID = Digest("twilight/turn/run", SessionID, TurnID, Attempt)`; +- Session kernel 新增 `CommitIn` 临界区合同,`Commit`(CAS)保留; +- 崩溃后同一 ModelStep 的 Recovered 仍重发同一冻结请求,本体按 `RequestDigest` 从 FrozenValueStore 取回。 + +### 6.4 代码迁移清单 + +保留(对存储位置无假设):`decide.go`、`evolve.go`、`next.go`、`ids.go`、`fact.go`、`state.go`、`model_data.go`、`clone.go`、`snapshot.go` 的 MachineState codec、`agent/run/loop` 的执行逻辑。 + +修改: + +| 项 | 内容 | +|---|---| +| `state.go` | 新增 `OwnerID`、`RunPosition`、`ModuleEvent`、`Companion` 接口;`MachineState` 加 `Owner`、`Attempt`,删 `LastModelResult`;`ModelStep.Request` 改为 `RequestDigest`;`ToolSpec` 删 `Definition`;`RunResult` 删 `Model` | +| `fact.go` | 删 `RunHeader`、`TransitionRecord`、`AgentEvent`;`ModelStepCompleted` 改为 `{StepID, Usage, FinishReason, ResultDigest}`;`ToolCallCompleted`/`ToolCallAnswered` 改为 digest;新增 `RunCreated{SchemaVersion, RunID, Owner, Attempt, CausationID}`;fact codec 输出 `jsonstable.Value`,payload `v` 由 Registry 加入 | +| `decide.go` | Prepare 校验 command 携带的本体 digest 后只写 digest;SubmitModelResult/SubmitToolResult 计算 ResultDigest/OutputDigest;`AcceptInput` 前置放宽为任意非终态;无 call 且有 pending 输入的 SubmitModelResult 回到 Open;新增 `WithdrawPreparedStep` | +| `evolve.go` / `next.go` | `ModelStepWithdrawn` 折叠为 Open;`Next` 在 Model Prepared 且有 pending 输入时返回 `WithdrawPrepared` | +| `commit.go` | `EvaluateCommit` 改为在 `SemanticTx` 内执行:LookupCommit、snapshot 加 tail fold、以 `RunPosition` 做 prepare hard CAS、从控制面 KV 读 lease、Decide、Evolve、companion、Attach、SourceDigest 校验,返回 `SemanticGroup` 与 lease ops、snapshot 决定 | +| `ids.go` | `DeriveModelRequestCommandID` 以 `RunPosition` 为 preimage;新增 run 事件 EventID 派生 | +| `agent/run/loop` | `Run(ctx, runtime, sessionID, runID, sink)`;Start 前 `Runtime.FrozenRequest`;ClaimStore key 加 SessionID;EventSink 的 `Committed` 改为 SessionCommit | +| `agent/turn` | 删 `mapper.go` 的 MaterializeAll、`ResultReference`、`MemoryLog`;`TurnID` 留在 turn,写入 Run 时转为 `OwnerID`;新增 `CompanionV1`、`Deliver`、`Retry`、`Settle`、surface 投影 | + +删除:`store.go`、`memory_store.go`、`stored_runtime.go`、`sqlitestore/`、`header.go`、`transition.go` 的 per-Run wire、`example_run_test.go` 的 per-Run Store 用法(改写为 Session Store 版本)。 + +新增:`agent/session` Memory Store(Commit、`CommitIn`、Types 过滤 replay、snapshot、控制面 KV 含条件写与 deadline 枚举)、`agent/session/extension`(FirstPartyRegistry、payload 版本、admission、SemanticAppender、Lease)、`agent/artifact` 两态 ledger 的 KV 实现、`agent/session/run`(module descriptor、machine projection、Runtime 实现、Memory FrozenValueStore、SnapshotPolicy)、golden fixtures 重新冻结。 + +## 7. 2026-09-04 修订(架构审查后) + +### 7.1 采纳的修正 + +| 审查意见 | 处理 | 位置 | +|---|---|---| +| Runtime 直写绕过 binding admission,companion 中的 ReferencePart 没有 claim | 只保留一条写入路径。`SemanticAppender` 增加临界区入口 `AppendSemanticIn`,Runtime 经它写入;companion 与 Attach 事件与其他 producer 一样经 codec、admission,claim 在同一事务建立 | EXT-SCP-1、EXT-APP-3、RUN-CMT-3、TRN-CMP-1 | +| commit 与 lease 的原子性无法由 SessionTx 实现;lease 丢失会使 Run 停滞 | Session Store 增加控制面 KV,`SessionTx` 内可读写、与 commit 同事务;lease、grant、artifact claim 都放在 KV。KV 与 stream 同一事务域,不会单独丢失。续期用 kernel 的条件写(见 7.6) | SES-API-3、RUN-CMT-7、RUN 5.1 | +| 单一 ProtocolVersion 重新耦合模块变更周期 | kernel 版本只覆盖 envelope、commit、snapshot envelope、digest profile;payload 第一层携带 `v`,Registry 按 `(EventType, v)` 选 codec 并永久保留旧版本;Run 恢复 `created.SchemaVersion` | SES-VER-1/2、EXT-REG-2、RUN-WIR-2、RUN-CMT-8 | +| v1 范围过大,Fork 等能力先于纵向切片 | Fork、ancestry、canonical import、resolved replay 移入 session 附录 A;Application module 与通用 Catalog 移入 extension 附录 B;两阶段 journal 移入附录 C;artifact 的 Prepared 状态、reconciler、迁移 fence、import/export 移入附录。v1 conformance 只覆盖 Memory Store 与纵向切片 | SES-SCP-2、EXT-SCP-2、ART-SCP-2 | +| 持久结构数量与一致性等级未写明 | 见 7.3 | 本节 | +| snapshot 每次 commit 重写且 `Results` 无界 | snapshot 改为可丢弃缓存,写入由 `SnapshotPolicy` 决定,`Load` 为 snapshot 加过滤 tail;终态 Run 从投影移除,结果由 `Record` 与 turn surface 的 `AttemptView.End` 提供 | SES-SNP-1/2、RUN-CMT-2、TRN-PRJ-1 | +| Retry 语义把产品策略写进协议 | 协议只保证失败 attempt 的内容留在 stream 与 ContextFold 输出中;是否进入请求由 Planner 决定,参考 Planner 的策略是全部纳入 | TRN-RTY-3、CHT-LIF-1、REF-PLN-6 | +| Prepare 对 chatlog 写入不敏感未声明 | 写明为有意选择,新鲜度由 Application 经 PlanningToken 负责,Run 不校验它 | RUN-CMT-4 | +| Run 内出现 TurnID | Run 只保留 opaque `OwnerID`,turn 以 TurnID 填充;run 不依赖 turn | RUN-SCP-2、TRN-SCP-1 | +| append fingerprint 含时间戳,崩溃重试得到 conflict | fingerprint 不再覆盖 `RecordedAtUnixMilli` | SES-APP-1 | +| Record 需要按 RunID 筛事件,Replay 无过滤 | `ReplayRequest.Types` 与 `SessionTx.Tail(types)` 前缀过滤;adapter 维护类型前缀到 revision 的索引 | SES-REP-2 | +| CoverageDigest 逐次重算 | `Through.Digest` 即 coverage 证明,删除独立 CoverageDigest | SES-SNP-1 | + +### 7.2 部分采纳 + +审查意见"三个 first-party module 实际是一个领域,应合为一个实现"。耦合证据成立,但它们指向的是固定的分层顺序(turn → run、turn → chatlog),可以用包依赖表达。合成一个包会失去读侧收益:投影按 EventType 命名空间筛选,Context 只读 chatlog、machine 只读 run。因此保留三个包与三个命名空间,推迟的是可插拔框架(附录 B),不是模块划分。 + +### 7.3 持久结构与一致性等级(第 8 节修订前;修订后见 8.4) + +| 结构 | 等级 | 写入点 | 丢失或不一致时 | +|---|---|---|---| +| Session commit 与 head | authority | `Commit` / `CommitIn` | 不可恢复;digest chain 使损坏可检测 | +| 控制面 KV:`twilight/run/lease`(经 extension.Lease) | 同事务控制面 | `AcquireLease`(start)、`ReleaseLease`(settlement、recovery)、`Leases.Renew`(续期,条件写) | 与 stream 同事务域,不会单独丢失;被运维误删时该 target 不再被过期枚举发现,需人工提交 Recover 命令 | +| 控制面 KV:`twilight/run/claim`(durable ClaimStore) | 同事务控制面 | Loop 经 Store | 退化为 lease 过期恢复 | +| 控制面 KV:`twilight/artifact/claim` | 同事务控制面 | `SemanticAppender` | GC 可能提前回收该 commit 引用的内容;可从 stream 中的 Binding 引用重建 | +| 投影 snapshot | 派生缓存 | `SnapshotPolicy` | 从 stream 重折 | +| FrozenValueStore | 旁存,生命周期为 ModelStep | 进入事务前 `Put` | Executing/Prepared step 的重发失败为不可重试错误,Application 决定 Retry;已终结 step 不受影响 | +| Artifact content store 与 BindingStore | 外部内容 | artifact owner | resolve 失败按 ART-CAP-1 分类 | + +v1 只有两类恢复动作:`RecoverExpired`(按 deadline 枚举过期 lease)与 Application 的 artifact GC(按 Active claim)。extension 附录 C 的 journal 扫描与 artifact 附录的 Prepared reconciler 不在 v1。 + +### 7.4 后续加固 + +lease 的第二条出路:grant 由 `(Claim, start CommitID)` 派生,start fact 记录 `ClaimDigest`,settlement 携带 Claim 时由 Runtime 从 stream 验证所有权。这样 lease 表完全退化为 deadline 缓存。改动涉及 fact wire 与 grant 签发,留待 v1 跑通后评估。 + +### 7.5 实施顺序 + +1. Session kernel Memory Store(Commit、CommitIn、Types 过滤、snapshot、控制面 KV 含条件写与 deadline 枚举)与 conformance; +2. extension FirstPartyRegistry、payload 版本、admission、SemanticAppender 两个入口、Lease;artifact 两态 ledger; +3. `agent/session/run`:module descriptor、machine projection、Runtime 实现(消费 SemanticAppender 与 Lease)、FrozenValueStore、RecoverExpired; +4. `agent/run` 按 6.4 修改,golden 重新冻结; +5. `agent/turn` attempt 模型、CompanionV1、surface 投影; +6. 参考组装跑通 Input → Turn → Run → Session 纵向切片,再接 live 模型。 + +### 7.6 租约的层次(2026-09-04,随后调整) + +审查后最初把 lease 写成 run 模块对 opaque KV 的约定,续期与结算存在竞争,并补了一个投影兜底扫描。随后考虑过把类型化的 lease 原语放进 kernel,被否决:kernel 不应持有"持有者"这类模块语义。最终切法: + +| 层 | 提供 | +|---|---| +| kernel(agent/session) | KV 条件写 `ControlCompareAndPut`、条目 deadline 字段、`ControlExpired` 枚举。不出现 lease 概念 | +| Module Framework(agent/session/extension) | 类型化的 `Lease`:Acquire / Release 在 `SemanticTx` 内、Renew 用条件写、Expired 用 deadline 枚举、Token 由 CommitID 派生。API 不含任何模块的概念 | +| run 模块(agent/session/run) | `Lease` 的第一个消费者:grant 即 Token,ExecutionClaim 即 Holder,过期后提交哪个 command | + +`Lease` 与 `SemanticAppender`、artifact claim 适配同属 EXT-SCP-3 定义的"kernel 机制之上的共用设施"。它现在只有一个消费者,仍放在 extension 而非 run 模块,原因是它的 API 不含 run 的概念、实现只依赖 kernel 原语,放在 run 里只会让下一个消费者(审批超时等)复制一份。由此删除:投影兜底扫描(KV 与 stream 同事务域,不会单独丢失)、"续期借 Session 锁"的方案、run 模块自己的 lease 值编码。 + +### 7.7 模块间依赖的声明(2026-09-05) + +此前模块间依赖只以 Go import 表达,Registry 不知道 turn 的投影消费 run 的事件,也不知道它能处理 run 事件的哪个版本。现在 `ModuleDescriptor` 增加 `Requires []ModuleRequirement`,Registry 构建时校验:被依赖模块已注册、依赖图无环、投影消费的事件类型在本模块或 `Requires` 范围内、被依赖事件的当前版本在声明的可处理版本内(EXT-REG-4)。三个模块的声明见 EXT-SCP-4。 + +曾考虑再加一对 `Needs` / `Provides` 表达"run 需要一个 Companion、turn 提供它"。否决:Companion 是 run Runtime 的构造参数,为 nil 时启动即失败,框架层的声明防不住任何额外的失效。`Requires` 只表达事件消费依赖。 + +### 7.8 回合中途追加输入(2026-09-05) + +`PendingInputs` 已是持久化队列,缺的是入队入口与消费时机。改动:Turn 增加 `Deliver`,每条输入一个 Run commit(`AcceptInput` 加 Attach 的 `input_delivered`);`AcceptInput` 前置从 `Open` 放宽为任意非终态;模型无 tool call 但有 pending 输入时 Run 回到 `Open` 而不结束;新增 `WithdrawPreparedStep`,Prepared 期间入队的输入使 `Next` 返回 `WithdrawPrepared`,Loop 放弃已冻结但未发出的请求并重规划。Executing 与 ToolStep 期间的输入等待该步结算,在随后的 `Open` 被 Prepare 一次消费,与 Codex、Claude Code 的注入点一致。Deliver 不打断进行中的调用;打断用 Stop。turn surface 消费 `run/input_accepted` 以跟踪全部输入,Retry 重放它们。 + +对照 pi 与 DeepSeek harness 的 inbox 模型后补齐了 session 级的路由:pi 的 steering 在当前 step 的工具结果之后注入、不中断生成也不跳过剩余 tool call,follow-up 只在 agent 本来要停下时取用;DeepSeek harness 的 inbox 是 `next-step` 与 `next-turn` 两条持久化列表,steer 在最近的 step 边界消费,turn 关闭前做最后一次 drain。twilight 的对应:`PendingInputs` 即 next-step;chatlog 中已 submitted 未 delivered 的输入即 next-turn;缺的"空闲时被唤醒、turn 结束后自动取下一条"由参考组装的 `SessionDriver` 提供(REF-DRV),协议不变。Stop 后 Retry 等价于 `cancel(keepInbox)`,Settle 等价于默认 cancel(TRN-STP-1)。 + +## 8. 2026-09-08 修订:Session 级单写者与扁平事件 + +### 8.1 起因 + +第 7 节形态的 Memory 栈跑通后(第 3 节),对照 dsh 与 Codex 的 session 日志实现发现:twilight 比它们多出的全部机制(`CommitIn` 临界区、`Commit` 的 CAS、控制面 KV、按目标的 lease 与 grant、`RenewLease` 心跳、`RecoverExpired` 按 deadline 枚举、commit 与 KV 同事务)都源于同一个假设:同一个 Session 可以有多个并发写者,包括不同进程。该假设没有部署需求支撑:Memoh 作为服务把一个 Session 固定到一个 worker,failover 走锁接管,不会两个 worker 同时写同一 Session;本地宿主是单进程。dsh 的做法(每 session 一个 write handle,进程内独占加跨进程 `flock`,第二个写者直接被拒)说明单写者足以支撑同类需求。 + +同时发现 `SessionCommit` 容器在读侧只是一层没有语义的嵌套(`ReplayPage.Commits[].Events[]`),它承担的三个作用中,幂等与 CAS 单位随单写者上移到进程内,commit 级元数据可以摊到每行,只剩"整组原子可见"一条,而这条只需要 append 以组为单位并在读侧不暴露不完整组,不需要嵌套类型。 + +### 8.2 决定 + +| 项 | 修订前 | 修订后 | +|---|---|---| +| 写者 | 多写者,`CommitIn` 回调式临界区,`Commit` CAS | 一个 Session 同一时刻一个 `Writer`(SES-OWN-1);`Open` 取所有权,Epoch 加一并持久化;落后 Epoch 的 `Append` 被拒(SES-OWN-2) | +| 写入单位 | `SessionCommit{Events[]}`,`(Revision, Index)` 定位 | 一行一个 `SessionEvent`,全局 `Seq`;同一次 `Append` 的行共用 `CommitID`,`Index`/`Last` 标记组;整组原子,不读不完整组(SES-APP-1/2) | +| 幂等 | kernel 按 `(SessionID, CommitID)` 加 fingerprint | kernel 只拒绝重复 CommitID;`extension.Writer` 以内存索引判定 AlreadyApplied / Conflict(EXT-WRT-2) | +| digest | header、event、commit、snapshot 四套,`ProtocolProfile` 12 个方法 | 每行一个 digest,覆盖本行与前一行(SES-WIR-2) | +| EventID | `Digest(Type, CommitID, index)` | 删除;`Seq` 即身份,`SourceSeqs` 引用 Seq | +| replay | `ReplayCursor{After: EventPosition, Token}` 分页 | `Read(sid, From, Types, Limit)`,Limit 在组边界截断 | +| `SourceEvents` 校验、`CausationID`/`CorrelationID` | kernel 校验引用存在;commit 级字段进 digest | 引用语义归声明它的模块;commit 级字段删除 | +| snapshot | kernel 的 `LoadSnapshot`/`SaveSnapshot`,与 commit 同事务 | 移到 extension 的可选 `ProjectionCache`,不与 Append 同事务(EXT-PRJ-3) | +| 控制面 KV、`extension.Lease`、grant | lease 按目标、TTL、条件写续期、deadline 枚举 | 全部删除。Session 所有权即执行所有权(RUN-CMT-6) | +| 恢复 | `RecoverExpired` 按过期 lease 逐目标恢复 | 接管者 `RecoverInterrupted` 对全部 Executing 目标一次性处置,Claim 为 `TakeoverClaim(SessionID, Epoch)`(RUN-CMT-7) | +| Loop | `RenewLease` 心跳、durable `ClaimStore`、grant 校验 | 都删除;Claim 只在 worker 内存中用于派生 CommandID;`ErrOwnershipLost` 为终止性错误(RUN-LOP-5) | +| artifact claim | `ActivateIn(kv)` 与 commit 同事务 | ledger 自持久化,`Activate` 在 Append 之前;孤儿 claim 由回收前核对释放(EXT-WRT-3、ART-RET-3) | +| `RequireComplete`、`ModuleForEvent` 按前缀猜模块 | 投影对未注册事件按模块归属拒绝 | 写者声明 `Ignorable`;范围内不可忽略的 Unknown 使 fold 失败,范围外跳过(EXT-PRJ-2) | +| extension 其他 | `JSONPointer` 提取、双入口 Appender、`LoadIn`/`SaveSnapshotIn` | 删除 | + +保留:Registry 的 `Requires` 校验(启动期)、`Types` 前缀过滤(读取优化)、canonical JSON 要求(digest 与跨 adapter 一致性的前提)、Run 的 `SchemaVersion`、companion 同组、Prepare 的 hard CAS、终态 Run 的 Load 兜底。 + +### 8.3 失去与得到 + +失去:同一 Session 的不同工具调用由不同进程并发执行(没有消费者);claim 与 commit 的同事务一致性(降为先 claim 后 append,孤儿由核对清理);修订前 conformance 中 grant 隔离、跨 Run grant、lease 续期的十几项断言。 + +得到:kernel 接口从 15 个方法降到 4 个,adapter 只需实现独占、追加与读,JSONL 成为一等实现;Runtime 去掉 lease/grant 两套校验;Loop 去掉心跳与 ClaimStore;与 dsh、Codex 的心智模型一致(一个 session 同一时刻一个写者)。 + +### 8.4 持久结构与一致性等级(修订后) + +| 结构 | 等级 | 写入点 | 丢失或不一致时 | +|---|---|---|---| +| Session header 与 event 行 | authority | `Writer.Append` | 不可恢复;按行 digest 链使损坏可检测;不完整尾组在打开时截掉 | +| 所有权记录(Epoch) | 控制 | `Open` | 接管由 Open 的 `Takeover` 声明,旧写者被 Epoch fencing | +| 投影缓存 | 派生缓存 | `SnapshotPolicy` | 从 stream 重折 | +| Writer 内存:幂等索引、投影状态、head | 派生 | `OpenWriter` 重建 | 随进程消失,重开时从日志重建 | +| FrozenValueStore | 旁存,生命周期为 ModelStep | Commit 之前 `Put` | Executing/Prepared step 的重发失败为不可重试错误 | +| artifact claim | 独立持久 | `Activate`,Append 之前 | 孤儿 claim 由回收前核对释放;不可能出现无 claim 的引用 | +| Artifact content store 与 BindingStore | 外部内容 | artifact owner | resolve 失败按 ART-CAP-1 分类 | + +v1 只有两类恢复动作:`RecoverInterrupted`(新 owner 一次性处置 Executing 目标)与 Application 的 artifact GC(回收前核对加按 Active claim 计算 root)。 + +### 8.5 实施顺序 + +1. `agent/session`:重写 Memory Store(Create、Header、Open/Epoch/Takeover、Append 整组、Read 过滤)与 conformance;删除 CommitIn、CAS、控制面 KV、snapshot、四套 digest、EventID、ReplayCursor; +2. `agent/session/extension`:`Writer`(OpenWriter 重建、Commit 串行、幂等索引、claim 先于 Append、ErrOwnershipLost 失效)、`Writers`、`ProjectionReader` 与 `ProjectionCache`、`Ignorable`;删除 SemanticAppender、Lease、LoadIn/SaveSnapshotIn、JSONPointer、ModuleForEvent 推断; +3. `agent/artifact`:ledger 改为自持久化 `Activate`,加 `OwnerVerifier` 与回收前核对; +4. `agent/run` 与 `agent/session/run`:`RunPosition = Seq`、`CommitResult.Events`、删除 grant/lease/RenewLease/RecoverExpired,新增 `RecoverInterrupted` 与 `TakeoverClaim`,machine 投影加 `Ended`; +5. `agent/run/loop`:删除 LeaseRenewInterval、ClaimStore、grant 路径;`ErrOwnershipLost` 处理;EventSink 携带 `[]SessionEvent`; +6. `agent/turn`:Coordinator 改为 `Writers`,Seq 定位,恢复表按 TRN-REC-2; +7. `agent/session/chatlog`:位置类型改 Seq; +8. `agent/ref`:按参考组装第 5 节重组,崩溃恢复 example 改为"关闭 Writer、以新 Epoch 打开、RecoverInterrupted、Resume"; +9. RUN-CMP-2 conformance 按修订后的清单重建;随后写文件 adapter,用 session 与 runtimetest 两套 conformance 验收。 + +后续协议修改直接更新对应正式规范;本文只更新迁移状态和历史决策,不再承载 wire、Machine、Runtime 或 Loop 算法。 diff --git a/docs/design/agent-session-chatlog.md b/docs/design/agent-session-chatlog.md new file mode 100644 index 0000000..c8c5aff --- /dev/null +++ b/docs/design/agent-session-chatlog.md @@ -0,0 +1,294 @@ +# Twilight Agent Session Chatlog Module + +状态:设计草案。`agent/session/chatlog` 已实现全部事件定义(含 checkpoint)、parts codec、PartsExtractor、Surface 与 Context 投影。payload 字段、输入 limits 与 golden fixtures 尚未冻结。 + +本文定义 `agent/session/chatlog` first-party Module,依赖 [Session](agent-session.md) 与 [Session Module Framework](agent-session-extension.md)。回合生命周期由 [Turn](agent-turn.md) 拥有。文中的“必须”“不得”“应该”是草案冻结时应保留的协议约束;canonical JSON 与 digest 遵循 `agent/jsonstable`、`agent/es`。 + +## 1. module 与 ontology + +```text +Source = twilight +ModuleID = chatlog +EventType = twilight/chatlog/ +Projections = twilight/chatlog/surface, twilight/chatlog/context +``` + +Chatlog 保存对话内容:Input、assistant、tool_result、summary、checkpoint。Surface 与 Context 是对这些 events 的纯投影。`assistant` 与 `tool_result` 携带 `TurnID`;Input 在 `input_delivered` 之后挂上 TurnID;summary 与 checkpoint 不携带 TurnID。回合的创建、attempt 与结束由 `twilight/turn/` 事件表达。外部内容经 `ReferencePart` 关联 Artifact BindingID。 + +`assistant` 与 `tool_result` 由 `run.Runtime` 作为 companion 事件,与产生它们的 `twilight/run/` 事实写在同一组(一次 `Append`,同一 CommitID;TRN-CMP)。Run 事实只记录内容 digest,内容本体只在 chatlog 事件中出现一次。companion 事件与其他 producer 的事件走同一条写入路径:`extension.Writer` 在 Append 之前执行 codec、Binding admission 并建立 claim(EXT-WRT-1、EXT-WRT-3),因此 companion 中的 `ReferencePart` 受到与用户输入相同的保护。 + +流式 `text_delta` / `reasoning_delta` 由 Loop EventSink 发送,属于临时观察。Chatlog 权威是已提交的条目。 + +**CHT-SCP-1** 本模块拥有对话内容。Application 拥有模型调用、provider transport、发送策略与审计。`turn` 拥有回合与 Run linkage。本模块的 `Requires`(EXT-REG-4)为空:事件中的 `TurnID` 是 opaque 字符串,不需要 turn 的 codec。 + +## 2. stable entity 与生命周期 + +```go +type TurnID string +type InputID string +type AssistantID string +type ToolResultID string +type SummaryID string +type CallID string +type CheckpointID string +``` + +`TurnID` 与 turn 模块同一 identity。InputID、AssistantID、ToolResultID、SummaryID 在 stream 内唯一;CallID 在同一 Turn 内唯一。replacement graph 无环,一个实体至多一个直接 replacement。 + +| 实体 | 创建 | 可变过程 | 终态/替换 | 不变量 | +|---|---|---|---|---| +| Input | `input_submitted` | 无 | delivered / withdrawn / rejected | 只终结一次;delivered 后进入 Context | +| Assistant | `assistant` | 无 | — | immutable;ID 单次创建 | +| Tool result | `tool_result` | 无 | 可被 `tool_result_superseded` | 同一 Turn、同一 CallID 至多一条 active | +| Summary | `summary` | 无 | 随 checkpoint 失效 | checkpoint 的摘要正文 | +| Checkpoint | `checkpoint_created` | 无 | invalidated | 指向已有 Seq | + +**CHT-LIF-1** reducer 拒绝 identity mutation、非法状态迁移、replacement conflict 与重复 ID。模型步骤进行中走 EventSink;定稿随 `ModelStepCompleted` / `ToolCallCompleted` 等 Run 事实同组写入 `assistant` 或 `tool_result`。同一 Turn 的多个 Run attempt 各自产生 assistant 与 tool_result,全部保留在 stream 中并出现在 ContextFold 的输出里;哪些条目进入模型请求由 Planner 决定(TRN-RTY-3、REF-PLN-6),本模块不作取舍。 + +## 3. parts 与条目 + +```go +type PartKind string +type ContentRefKind string +const ( + PartText PartKind = "twilight/chatlog/text" + PartReasoning PartKind = "twilight/chatlog/reasoning" + PartToolCall PartKind = "twilight/chatlog/tool_call" + PartReference PartKind = "twilight/chatlog/reference" + RefArtifactBinding ContentRefKind = "twilight/chatlog/artifact_binding_ref" +) +type Part interface { PartKind() PartKind } +type ContentRef interface { RefKind() ContentRefKind } +type ArtifactBindingRef struct { BindingID artifact.BindingID } +func (ArtifactBindingRef) RefKind() ContentRefKind +type ReferencePart struct { Ref ContentRef; Name string } +func (ReferencePart) PartKind() PartKind +type TextPart struct { Text string } +func (TextPart) PartKind() PartKind +type ReasoningPart struct { Text string } +func (ReasoningPart) PartKind() PartKind +type ToolCallPart struct { + CallID CallID // Run 派生的 CallID,同一 Turn 内唯一 + ProviderCallID string // 模型发出的 tool_call_id,供 Planner 回传配对 + Name string + Input jsonstable.Value +} +func (ToolCallPart) PartKind() PartKind + +type ToolResultStatus string +const ( + ToolSuccess ToolResultStatus = "success" + ToolError ToolResultStatus = "error" + ToolUnknown ToolResultStatus = "unknown" +) + +type Input struct { + ID InputID + TurnID TurnID // delivered 之后赋值;与 turn 模块同一 identity + Content jsonstable.Value + Digest es.Digest +} +type Assistant struct { + ID AssistantID + TurnID TurnID + Parts []Part + SourceDigest es.Digest // 产生它的 Run fact 记录的冻结值 digest(TRN-MAP-3);非 Run 产生时为空 + Digest es.Digest +} +type ToolResult struct { + ID ToolResultID + TurnID TurnID + CallID CallID + Status ToolResultStatus + Parts []Part + SourceDigest es.Digest // 同上 + Digest es.Digest +} +type Summary struct { + ID SummaryID + Parts []Part + Digest es.Digest +} + +type EntryKind string +const ( + EntryInput EntryKind = "input" + EntryAssistant EntryKind = "assistant" + EntryToolResult EntryKind = "tool_result" + EntrySummary EntryKind = "summary" +) +type Entry struct { + Kind EntryKind + ID string + Digest es.Digest + Input *Input + Assistant *Assistant + ToolResult *ToolResult + Summary *Summary +} +``` + +**CHT-ENT-1** Parts 有序。`ArtifactBindingRef` 的 identity 为 discriminator 与 BindingID。interface value 非 nil;part kind 与 concrete value 匹配。ReferencePart 的 MediaType 来自 Artifact Ref。 + +**CHT-ENT-2** 每个 `(TurnID,CallID)` 在 assistant 中至多一个 ToolCall。`tool_result` 对应同 Turn 已有的 call。CallID 在同一 Turn 内唯一:同一 Turn 的后续 ModelStep 与后续 Run attempt 不得复用已出现的 CallID(CallID 由 `(ModelStepID, index)` 派生,ModelStepID 含 RunID,天然满足)。`unknown` 为 companion 写入的未决终态。active Context 视 unresolved call 为未解决,直到 Application 在 Turn 尚未 `twilight/turn/completed` 或 `twilight/turn/failed` 时写入 `tool_result_superseded`,换成 `success` 或 `error`。每个 unresolved result 至多一个 replacement。v1 companion 不写 `tool_result_superseded`。 + +**CHT-ENT-3** ToolResult 的 nested Parts 为单层 TextPart 或 ReferencePart。外部内容使用 `ReferencePart`。 + +**CHT-ENT-4** 用户侧内容是 Input。`input_delivered` 把 Input 挂到 TurnID;Context 将已 delivered 的 Input 作为用户条目。 + +## 4. canonical wire codec + +运行时是 typed model;wire 是 static registry 的 discriminated union。 + +```go +type PartCodec interface { + Kind() PartKind + EncodePart(Part) (jsonstable.Value, error) + DecodePart(jsonstable.Value) (Part, error) +} +type ContentRefCodec interface { + Kind() ContentRefKind + EncodeRef(ContentRef) (jsonstable.Value, error) + DecodeRef(jsonstable.Value) (ContentRef, error) +} +``` + +**CHT-COD-1** Decode 先检查 object、discriminator、unknown fields 和 limits,再构造 typed value;payload 版本字段 `v` 由 Registry 处理(EXT-REG-2),本模块 codec 不读写它。Encode/Decode 拒绝 nil、kind mismatch、未知 discriminator、cycle 和超限输入。有效值满足 `Encode → Decode → Encode` canonical-equivalent。 + +**CHT-COD-2** parts codec 在启动时固定,kind 有唯一 codec。本模块提供 `PartsExtractor`,实现 `extension.BindingExtractor`,按 appearance order 返回 assistant、tool_result、summary 中 ReferencePart 的 BindingID,随这三种 EventDefinition 一起声明。 + +**CHT-COD-3** EventType 为 `twilight/chatlog/`。条目 Digest 的 domain 与 EventType 相同,覆盖 ID、TurnID(若有)、有序 parts 或 Content、ref identity、SourceDigest(若有),不覆盖 `v`: + +```text +Digest("twilight/chatlog/input_submitted", ...) +Digest("twilight/chatlog/assistant", ...) +Digest("twilight/chatlog/tool_result", ...) +Digest("twilight/chatlog/summary", ...) +``` + +v1 freeze 前开放项:wire field names、输入 limits、golden fixtures。 + +## 5. event payloads + +payload 为 object,identity 为 string,整数按 Session profile 编码。未列字段在 v1 拒绝。 + +```go +type InputSubmittedPayload struct { + InputID InputID + Content jsonstable.Value + SubmittedAtUnixMilli int64 +} +type InputDeliveredPayload struct { InputID InputID; TurnID TurnID } +type InputWithdrawnPayload struct { InputID InputID; Reason string } +type InputRejectedPayload struct { InputID InputID; Reason string } + +type AssistantPayload struct { Assistant Assistant } + +type ToolResultPayload struct { ToolResult ToolResult } +type ToolResultSupersededPayload struct { ToolResultID ToolResultID; ReplacementToolResultID ToolResultID } + +type SummaryPayload struct { Summary Summary } + +type CheckpointCreatedPayload struct { + CheckpointID CheckpointID + CoveredThrough session.Seq + BaseContextDigest es.Digest + SummaryID SummaryID + SummaryDigest es.Digest + Retained []EntryDigestPair + Digest es.Digest +} +type EntryDigestPair struct { Kind EntryKind; ID string; Digest es.Digest } +type CheckpointInvalidatedPayload struct { CheckpointID CheckpointID; Reason string } +``` + +`assistant`、`tool_result`、`summary` 的 EventDefinition 声明 parts 提取: + +```go +extension.BindingReferenceDefinition{ + Extractor: chatlog.PartsExtractor, + Cardinality: extension.Cardinality{Min: 0}, + AllowedSchemes: nil, + RequiredDurability: artifact.EventBound, +} +``` + +`AllowedSchemes` 为空表示任意已注册且支持 `EventBound` 的 scheme。 + +**CHT-EVT-1** EventType: + +```text +twilight/chatlog/input_submitted +twilight/chatlog/input_delivered +twilight/chatlog/input_withdrawn +twilight/chatlog/input_rejected +twilight/chatlog/assistant +twilight/chatlog/tool_result +twilight/chatlog/tool_result_superseded +twilight/chatlog/summary +twilight/chatlog/checkpoint_created +twilight/chatlog/checkpoint_invalidated +``` + +**CHT-EVT-2** `input_submitted` 创建 Input。Delivered、Withdrawn、Rejected 各终结一次。`input_delivered` 要求 Input 仍为 submitted,并写入非空 TurnID;它与把该输入交给 Run 的事实同组:Start group 中与 `twilight/turn/started` 一起,回合中途与 `twilight/run/input_accepted` 一起(TRN-STR-2、TRN-DLV-2)。AssistantID、ToolResultID、SummaryID 在 stream 内单次创建。 + +**CHT-EVT-3**(checkpoint)checkpoint 压缩 active Context:合法 checkpoint 使其变为 `[Summary] + Retained`,其后的事件照常折叠。digest 规则:`Digest` 的 domain 为 `twilight/chatlog/checkpoint_created`,覆盖除 `Digest` 外的全部字段;`BaseContextDigest` 以同一 domain 对 `{base: [(Kind, ID, Digest)]}` 计算,覆盖截至 `CoveredThrough` 的有序 active Context 序列;`Retained` 为空与省略是同一 wire 值,两个 digest 预映像都把空列表折叠为 nil。summary 应与 checkpoint 同组提交,gap 不变量因此原子成立。 + +fold 在提交前逐条校验(EXT-WRT-1 的投影预折叠),违反者整组拒绝:`CoveredThrough` 早于 checkpoint 行的 Seq;`CoveredThrough` 与 checkpoint 之间的 Context 条目恰为该 `SummaryID` 的 summary 且 digest 相符;`BaseContextDigest` 与 base 序列重算值相符;`Retained` 是 base 序列的有序子集(逐项 (Kind, ID, Digest) 全等)。retained 集的 provider 合法性(tool_call 与 result 的配对封闭)是 Application 的职责(REF-CKP-2),fold 不校验。 + +`checkpoint_invalidated` 只能指向最近一个仍 active 的 checkpoint:active Context 回到 base 加 checkpoint 之后折叠的尾部,summary 条目随之离开 active Context(Surface 与历史保留);连续 invalidate 逐层回退。指向被压缩条目的 `tool_result_superseded` 是协议违规而非 checkpoint 失效条件:被压缩条目的 Turn 已结束,CHT-ENT-2 已排除对它的 supersede。相对早期草案的修订(首个实现按预留的修订权收窄):失效途径只有显式 invalidate 最近的 active checkpoint,不存在"summary/Retained/base source 被 supersede 引发的隐式失效"。 + +## 6. Surface projection + +```go +type SurfaceEntry struct { + Kind EntryKind + ID string + Seq session.Seq +} +type Surface struct { + Inputs map[InputID]Input + Assistants map[AssistantID]Assistant + ToolResults map[ToolResultID]ToolResult + Summaries map[SummaryID]Summary + EntryOrder []SurfaceEntry +} +``` + +**CHT-SUR-1** SurfaceFold 消费 chatlog decoded events,其他事件按 EXT-PRJ-2 处理。`EntryOrder` 为 stream 顺序下的 delivered input、assistant、tool_result、summary,并带 Seq。回合列表由 turn 投影提供,按 `TurnID` 连接。checkpoint 记录于 `Surface.Checkpoints`(active / invalidated);compaction 不改动 `EntryOrder`(全量历史保持可见),也不触及输入队列——排队中的输入不在 Context 条目里,不可能被压缩。 + +## 7. Context projection + +```go +func ContextFold(events []extension.DecodedEvent) ([]Entry, error) +``` + +**CHT-CTX-1** 输入为已验证、按 stream 顺序的 chatlog events,其他事件按 EXT-PRJ-2 处理。输出为 delivered input、assistant、tool_result、summary 经 supersession 与 checkpoint 处理后的有序 `[]Entry`。ContextFold 为纯函数。 + +**CHT-CTX-2** fold 执行 ID 单次创建、CallID pairing、unresolved-call 与 replacement 规则。合法 checkpoint 按 CHT-EVT-3 应用。Context 只含已 delivered 的 Input。 + +## 8. materializer port + +```go +type MaterializationTarget struct { Name string; Capabilities []string } +type MaterializationDecision struct { + Kind EntryKind; ID string + BindingID artifact.BindingID + Operation string; Result string; Detail string +} +type MaterializedEntry struct { Kind EntryKind; Parts []jsonstable.Value } +type MaterializationResult struct { Entries []MaterializedEntry; Decisions []MaterializationDecision } +type ContextMaterializer interface { + Materialize(context.Context, []Entry, MaterializationTarget) (MaterializationResult, error) +} +``` + +**CHT-MAT-1** materializer 把语义条目转为目标模型表示。committed chatlog Events 保持不变。provider capability 与发送策略由 Application 决定。Planner 组装见 [参考组装](agent-reference-assembly.md)。 + +## 9. conformance + +- **CHT-LIF-1、CHT-EVT-1、CHT-EVT-2**:所列 EventType、Input 终结一次、delivered 带 TurnID、ID 单次创建; +- **CHT-ENT-1 至 CHT-ENT-4**:parts、CallID pairing、replacement、用户侧为 Input; +- **CHT-COD-1 至 CHT-COD-3**:codec;Digest domain 与 EventType 相同; +- **CHT-SUR-1、CHT-CTX-1、CHT-CTX-2**:EntryOrder;checkpoint 的折叠、显式失效回退与非法 checkpoint 的整组拒绝(含排队输入不受压缩影响); +- **CHT-MAT-1**:materializer 为 IO 边界。 diff --git a/docs/design/agent-session-extension.md b/docs/design/agent-session-extension.md new file mode 100644 index 0000000..a74f902 --- /dev/null +++ b/docs/design/agent-session-extension.md @@ -0,0 +1,245 @@ +# Twilight Agent Session Module Framework + +状态:设计草案。已由 `agent/session/extension` 实现(Writer、Writers、ProjectionReader 与 MemoryProjectionCache)并通过第 7 节的测试;wire 在文件 adapter 通过前不冻结。写入串行与幂等重放由进程内的 `Writer` 承担,kernel 只提供追加日志([agent-session.md](agent-session.md));此前的 Appender 与 Lease 设计及其收缩决定见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节。 + +本文定义建立在 `agent/session` 与 `agent/artifact` 之上的 Session Module Framework。实现包路径为 `agent/session/extension`;文中的"必须""不得""应该"是协议约束;JSON canonicalization 与 digest 遵循 `agent/jsonstable`、`agent/es`。 + +## 1. 范围与依赖 + +```text +agent/artifact ← Session Module Framework → agent/session + ↑ + first-party modules: chatlog、turn、run +``` + +Framework 负责:typed event codec 与 payload 版本;Binding admission;进程内的写入串行、幂等重放与 claim 顺序(`Writer`);pure projection 与投影缓存。first-party Source 为 `twilight`,Module 为 `chatlog`、`turn`、`run`。 + +**EXT-SCP-1** 一个 Session 在一个进程内恰有一个 `Writer`,它持有 kernel 的 `session.Writer`(所有权句柄)。全部写入经 `Writer.Commit`:Run 的 Runtime、Turn 的 Coordinator、接管恢复都是它的调用方。模块读取投影经 `ProjectionReader`。 + +**EXT-SCP-2** 模块集合由组装代码在启动时传入 `BuildRegistry`,运行期不变;本层不 import 任何模块包。first-party 恰为三个 module;application module 与它们同构、经装配开口注册,见第 8 节。 + +**EXT-SCP-3** 模块间依赖单向、固定,以 `Requires` 声明并由 Registry 校验(EXT-REG-4)。v1 三个模块的声明: + +| 模块 | Requires | +|---|---| +| `chatlog` | 无 | +| `run` | 无。`Companion` 是 Runtime 的构造参数,不是模块依赖 | +| `turn` | `run`(`twilight/run/run_created`、`input_accepted`、`run_ended` v1)、`chatlog`(存在即可) | + +## 2. Registry 与版本 + +```go +type SourceID string +type ModuleID string +type ProjectionID string +type ProjectionVersion uint16 +type PayloadVersion uint16 + +const SourceTwilight SourceID = "twilight" + +// ModuleKey 是模块在 Registry 中的身份:(Source, ID) 二元组。 +type ModuleKey struct { Source SourceID; ID ModuleID } + +type EventDefinition struct { + Type session.EventType + Current PayloadVersion + Codecs map[PayloadVersion]PayloadCodec + Bindings []BindingReferenceDefinition + // Ignorable 为真的事件写入时带 session.SessionEvent.Ignorable,供不认识它的 reader 跳过。 + Ignorable bool +} +type ModuleDescriptor struct { + Source SourceID + ID ModuleID + Requires []ModuleRequirement + Events []EventDefinition + Projections []ProjectionDefinition +} +type ModuleRequirement struct { + Source SourceID // 必填:依赖以 (Source, Module) 指认 + Module ModuleID + Events map[session.EventType][]PayloadVersion +} +type Registry struct { ProtocolVersion uint16 /* immutable indexes */ } +func BuildRegistry(protocolVersion uint16, modules ...ModuleDescriptor) (*Registry, error) +func ModulePrefix(source SourceID, id ModuleID) session.EventType // // +func (r *Registry) LookupEvent(session.EventType) (ModuleKey, EventDefinition, bool) +func (r *Registry) ModuleOf(session.EventType) (ModuleKey, bool) // 按 // 前缀 +func (r *Registry) Encode(session.EventType, any) (jsonstable.Value, PayloadVersion, error) +func (r *Registry) Decode(session.SessionEvent) (DecodedEvent, error) +``` + +**EXT-REG-1** EventType 为 `//`。Source 与 ModuleID 是非空、不含 `/` 的合法 UTF-8 段;模块身份是 `(Source, ID)` 二元组,同一 Registry 中该二元组、EventType、ProjectionID 均唯一(同名 ModuleID 可在不同 Source 下共存)。`twilight` Source 保留给本仓库的 first-party 模块,application module 必须使用自己的 Source。`BuildRegistry` 校验每个 EventDefinition 的 Type 前缀等于其模块的 `//`,构建后只读。 + +**EXT-REG-2** payload 版本与 kernel 版本分离(SES-VER-1)。payload object 第一层携带整数字段 `v`;`Encode` 写入 `Current`,`Decode` 读 `v` 并选择 `Codecs[v]`。旧版本 codec 永久保留,旧事件不迁移。 + +**EXT-REG-3** `Decode` 对未注册的 EventType 或未注册的 `v` 返回 `DecodedEvent{Unknown:true}` 并保留原始 payload。投影对 Unknown 的处置见 EXT-PRJ-2。 + +**EXT-REG-4** 模块间依赖由 `Requires` 声明,构建时校验:被依赖模块已注册、依赖图无环、投影消费的 EventType 属于本模块或 `Requires` 中的模块、被依赖事件的 `Current` 在声明的版本列表内。`Requires` 只表达事件消费依赖;接口实现(如 run 的 `Companion` 由 turn 实现)是构造参数,不进入 `Requires`。 + +## 3. event codec + +```go +type PayloadCodec interface { + Encode(value any) (jsonstable.Value, error) // 不含 v;Registry 加入 + Decode(wire jsonstable.Value) (any, error) + Validate(value any) error +} +type DecodedEvent struct { + Event session.SessionEvent + Module ModuleKey + Version PayloadVersion + Value any + Unknown bool +} +``` + +**EXT-COD-1** codec、Validate、Binding extraction 必须纯、确定、无 IO。Decode wire-first。Encode/Decode 拒绝 nil、typed nil、kind mismatch、未知 kind 与非 canonical value。有效值满足 `Encode → Decode → Encode` 的 canonical round-trip;该性质是模块的测试义务(每个注册事件类型一条往返断言),Registry 的 Encode 不在运行期重验。 + +**EXT-COD-2** 已提交事件的 payload 保持原始 canonical bytes。`v` 由 Registry 在 Encode 后加入、Decode 前取出;payload 的其他第一层字段不得命名为 `v`。 + +## 4. Binding reference declaration 与 admission + +```go +type Cardinality struct { Min uint32; Max *uint32 } +type BindingExtractor interface { + BindingIDs(value any) ([]artifact.BindingID, error) // appearance order +} +type BindingReferenceDefinition struct { + Extractor BindingExtractor + Cardinality Cardinality + AllowedSchemes []artifact.Scheme + RequiredDurability artifact.Durability +} +``` + +**EXT-REF-1** 声明以 `Extractor` 提取 typed value 内的全部 Artifact 引用,保留 appearance order,随后 group 才 sorted-unique。Extractor 随 EventDefinition 声明,本层不维护提取器注册表,也不提供路径式(JSONPointer)提取。 + +**EXT-REF-2** `BuildRegistry` 验证 cardinality、Extractor 非 nil 与 scheme/durability 声明;最低 durability 至少为 `EventBound`。admission 解析每个 Binding,验证 Scheme、最低 durability、resolvability;任何违反拒绝整个 group,不作任何写入。 + +## 5. Writer:进程内的写入串行与幂等 + +```go +type TypedEvent struct { + Type session.EventType + RecordedAtUnixMilli int64 + SourceSeqs []session.Seq + Value any +} +type SemanticGroup struct { + CommitID session.CommitID + Events []TypedEvent +} +// View 是 Commit 回调内可读的一致视图:head、幂等索引、投影状态。 +type View interface { + Head() session.Head + Epoch() session.Epoch + LookupCommit(session.CommitID) ([]session.SessionEvent, bool) + Projection(ProjectionID, ProjectionVersion) (any, error) // 折叠到当前 head 的状态 +} +type CommitFn func(View) (*SemanticGroup, error) // nil 表示不写 + +type CommitOutcome string +const ( + CommitApplied CommitOutcome = "applied" + CommitAlreadyApplied CommitOutcome = "already_applied" // 同 CommitID、同 fingerprint + CommitConflict CommitOutcome = "conflict" // 同 CommitID、不同 fingerprint + CommitInvalid CommitOutcome = "invalid" + CommitNoop CommitOutcome = "noop" +) +type CommitResult struct { + Outcome CommitOutcome + Events []session.SessionEvent + Claim *artifact.RetentionClaim + Detail string +} + +type Writer interface { + SessionID() session.SessionID + Epoch() session.Epoch + Commit(context.Context, CommitFn) (CommitResult, error) + Projections() ProjectionReader // 读取本 Writer 维护的投影 + Close(context.Context) error +} +func OpenWriter(ctx, store session.Store, registry *Registry, ledger artifact.RetentionLedger, sid session.SessionID, opts session.OpenOptions) (Writer, error) +``` + +**EXT-WRT-1** `OpenWriter` 调 `store.Open` 取得所有权,读取整条日志重建三样内存状态:幂等索引(CommitID → 该组的行与 fingerprint)、每个已注册投影的当前状态、head。之后 `Commit` 在 Writer 的互斥区内执行:调 fn 得到 group,做 codec、admission、claim,`session.Writer.Append`,再把新行折进投影并更新索引。fn 只能通过 `View` 读;fn 返回 nil 记 `Noop`。Writer 是并发的唯一入口:Run 的 worker、Coordinator、恢复流程都经它串行,kernel 不再需要临界区回调。 + +**EXT-WRT-2** 幂等:fn 返回的 group 若 CommitID 已在索引中,比对 fingerprint(Type、SourceSeqs、Payload 的有序序列,不含时间),相同返回 `AlreadyApplied` 与原行,不同返回 `Conflict`;两者都不写入,也不做 admission 与 claim。fn 内可先经 `View.LookupCommit` 判断,避免为重放重新构造 group。 + +**EXT-WRT-3** claim 顺序:group 含 Binding 时,Writer 在 `Append` 之前调用 `ledger.Activate(claimID, owner, set)`。顺序固定为先 claim 再 append,因此崩溃只可能留下孤儿 claim(有 claim 无 commit),不可能留下无 claim 的引用;孤儿由 artifact 的回收前核对释放(ART-RET-3)。`Append` 失败时 Writer 调用 `ledger.ReleaseActive(claimID)` 尽力回收,失败也只留孤儿。 + +**EXT-WRT-4** `Append` 返回 `ErrOwnershipLost` 时 Writer 进入失效状态:本次与之后的 `Commit` 返回该错误,调用方必须放弃该 Session 的执行。这是 Session 级 fencing 在进程内的表现;Runtime 与 Loop 对它的处理见 RUN-CMT-6。 + +**EXT-WRT-5** ClaimID 派生规则:`Digest("twilight/session-extension/claim", "1", ProtocolVersion, SessionID, CommitID, RefSetDigest)`;`ClaimOwner = {Kind:"twilight/session/commit", Authority:SessionID, Identity:CommitID}`。 + +```go +// Writers 是宿主维护的 SessionID → Writer 映射;模块(run 的 Runtime、turn 的 Coordinator)经它取得 Writer。 +type Writers interface { + Writer(context.Context, session.SessionID) (Writer, error) +} +``` + +**EXT-WRT-6** 一个进程对同一 Session 只打开一个 Writer,`Writers` 负责这一唯一性:首次请求时 `OpenWriter`,之后返回同一实例;Writer 失效(EXT-WRT-4)或 Close 后再次请求返回错误,是否重新 Open 由宿主决定。模块不自行调用 `OpenWriter`。 + +## 6. pure projection 与缓存 + +```go +type ProjectionDefinition struct { + ID ProjectionID; Version ProjectionVersion + Consumes []session.EventType + Initial func() (any, error) + Apply func(any, DecodedEvent) (any, error) + StateCodec PayloadCodec +} +type ProjectionReader interface { + // through 是该状态覆盖的 stream head:Next 为下一未折叠行的 Seq,Digest 为最后一行的 digest。 + Load(ctx, sid session.SessionID, id ProjectionID, v ProjectionVersion) (state any, through session.Head, err error) +} +// ProjectionCache 是可选的派生缓存,随时可删;Memory 实现由本层提供。 +type ProjectionCache interface { + Load(ctx, sid, id, v) (state jsonstable.Value, through session.Head, ok bool, err error) + Save(ctx, sid, id, v, state jsonstable.Value, through session.Head) error +} +func NewProjectionReader(store session.Store, registry *Registry, cache ProjectionCache) ProjectionReader +``` + +**EXT-PRJ-1** Initial、Apply、StateCodec 必须 pure。Fold 以组为单位:一组内任一 event 的 Apply 失败,不发布该组的部分状态。 + +**EXT-PRJ-2** 投影只处理 `Consumes` 中的 EventType。其他 EventType 按归属处理:属于本模块或 `Requires` 模块(EXT-REG-4 的范围)且 `Decode` 为 Unknown 的事件,`Ignorable` 为真则跳过,否则 Fold 失败;范围之外的模块的事件一律跳过。写入者对纯信息性事件声明 `Ignorable`(EXT-REG),默认不可忽略:忘记声明只会导致多拒绝,不会导致静默丢失。读取时以范围内模块的前缀作为 `Types` 过滤。 + +**EXT-PRJ-3** 缓存条目记录 `through`:已折叠到的 stream head(`Next` 为下一未折叠行的 Seq,`Digest` 为最后一行的 digest)。复用条件:`Read(From: through.Next-1)` 返回的首行 Digest 等于 `through.Digest`,且 `StateCodec.Decode` 成功;否则从头重折。写入策略由投影或其宿主决定(例如 run 的 `SnapshotPolicy`);缓存不在 kernel,也不与 append 同事务,丢失或过期只影响读取代价。 + +**EXT-PRJ-4** `Writer.Projections()` 返回的 reader 直接读 Writer 内存中的状态,不经 Store;独立进程的观察者用 `NewProjectionReader` 从 Store 读,两者对同一 head 给出相同状态。 + +## 7. errors 与 conformance + +```go +type ErrorCode string +const ( + ErrInvalid ErrorCode = "invalid"; ErrUnknownEvent ErrorCode = "unknown_event" + ErrCodec ErrorCode = "codec"; ErrBinding ErrorCode = "binding" + ErrConflict ErrorCode = "conflict"; ErrOwnershipLost ErrorCode = "ownership_lost" +) +``` + +v1 conformance 必须验证: + +- **EXT-REG-1 至 4**:immutable Registry、`v` 的写入与选择、多版本 codec 共存、Unknown 保留 raw payload、`Requires` 缺失或成环被拒绝、投影消费范围外事件被拒绝、被依赖事件版本不在声明范围被拒绝;Source 段非法(空、含 `/`、非 UTF-8)被拒绝、`(Source, ID)` 重复被拒绝、同名 ModuleID 在不同 Source 下共存且各自前缀可解析; +- **EXT-COD-1/2**:wire-first、`v` 保留字段;canonical round-trip 由各模块的测试覆盖; +- **EXT-REF-1/2**:Extractor 全量提取、cardinality、scheme/durability admission、拒绝时无写入; +- **EXT-WRT-1 至 5**:OpenWriter 后索引与投影等于全量 fold;同 CommitID 重放 AlreadyApplied、不同内容 Conflict、两者无写入;并发调用方串行且各自看到前一次的结果;claim 先于 append,append 失败后 claim 被释放或可被核对回收(claim 相关断言随第一个真实内容存储冻结,见 artifact spec 状态段);`ErrOwnershipLost` 后 Writer 失效; +- **EXT-PRJ-1 至 4**:pure fold、组边界、Consumes 与范围外跳过、Ignorable 与非 Ignorable 的 Unknown、缓存复用条件、Writer 内投影与 Store 读取一致。 + +## 8. Application module + +Application 在自己的代码里定义 `ModuleDescriptor`(自有 Source 下的事件类型、codec、投影),经装配开口(参考装配为 `ref.Options.Modules`)与 first-party 模块一起传入 `BuildRegistry`。app module 与 first-party 模块同构、同权:同一 Registry、同一 `Writer.Commit` 提交路径、同一投影框架。 + +**EXT-APP-1(承诺面)** app module 的 `Requires` 可依赖 first-party 模块的事件;三个 first-party 模块各事件的当前 payload 版本即稳定消费面。first-party 推进 `Current` 时,未声明新版本的 app module 在 `BuildRegistry` 即失败(EXT-REG-4 的握手校验),不会在运行期静默错读。 + +**EXT-APP-2(隔离)** EXT-PRJ-2 的范围规则双向保护:first-party 投影对 app 模块(范围外)的事件一律跳过;app 投影对未列入其 `Requires` 的模块同样跳过。app module 未注册时,其历史事件对所有投影是范围外事件,按 EXT-REG-3 保留原始 payload、不参与折叠。 + +**EXT-APP-3(适用判据)** 需要"持久、可重放、参与投影"的事实才建 module;工具、模型、系统提示、planner、观测 sink 走既有接口扩展点(参考装配的 Agent/Profile、EventSink、Store adapter),不进 Session 流。 + +通用 `Catalog`(把多个 Source 的 ModuleDescriptor 与 artifact SchemeDefinition 组合为只读索引的独立一层)仍不进入 v1:模块以 Go 值直接传入 `BuildRegistry`。 diff --git a/docs/design/agent-session.md b/docs/design/agent-session.md new file mode 100644 index 0000000..d5d709d --- /dev/null +++ b/docs/design/agent-session.md @@ -0,0 +1,174 @@ +# Twilight Agent Session Protocol + +状态:设计草案。已由 `agent/session` 的 MemoryStore 实现并通过第 7 节 conformance(`agent/session/sessiontest`,以 Store 为参数);wire 在文件 adapter 也通过前不冻结。此前的多写者设计(临界区、commit 容器、控制面 KV、kernel 内 snapshot)及其收缩决定见 [agent-runtime-refactor.md](agent-runtime-refactor.md) 第 8 节。 + +本文定义 Twilight Session 的 Event Sourcing kernel。文中的"必须""不得""应该"是协议约束。 + +## 1. 范围 + +```text +Events = 一条 Session 的有序 SessionEvent 日志,追加式,一行一个 event +State = Fold(Events) + +kernel 负责:header、event 行、seq、原子的组追加、Session 级写者独占、按行 digest、顺序读 +modules 负责:event ontology、typed codec、payload 版本、投影、投影缓存、幂等重放、并发串行 +``` + +**SES-SCP-1** kernel 不解释 payload,不校验 payload 的 schema,不知道模块、commit 的语义、投影或 lease。它保证四件事:日志只能追加;同一时刻一个 Session 至多一个有效写者;一次 `Append` 的整组 event 同时可见或同时不存在;每行携带覆盖前一行的 digest。 + +**SES-SCP-2** 并发不在 kernel 解决。一个 Session 的全部写入者(Run 的 worker、Turn 的 Coordinator、恢复流程)在进程内经同一个 `extension.Writer` 串行(EXT-WRT),Writer 持有 kernel 的写者句柄。kernel 只拒绝不持有有效所有权的 `Append`。 + +**SES-SCP-3** v1 的范围是单条 stream:header、Open/Append/Read、所有权与 epoch、按行 digest。Fork、ancestry、canonical import 见附录 A,v1 返回 `ErrUnsupported`。 + +## 2. 版本 + +`ProtocolVersion` 覆盖 kernel wire:header 字段、event 行字段、digest preimage、组完整性规则。它不覆盖 payload。 + +**SES-VER-1** payload 的版本由模块负责:每个 payload object 第一层携带整数字段 `v`,模块按 `(EventType, v)` 选 codec(EXT-REG-2)。kernel 不读取该字段。 + +**SES-VER-2** `ProtocolVersion` 在旧 reader 无法保持行结构或 digest 语义时递增;payload、EventType、模块 codec 的变化不触发。kernel 版本变化由外部 migration tool 生成新版本日志,旧日志原样保留(adjacent migration)。 + +## 3. wire types + +```go +type SessionID string +type CommitID string +type EventType string +type Seq uint64 // 行号,从 0 连续递增 +type Epoch uint64 // 写者所有权代数,从 1 递增 + +type SessionHeader struct { + ProtocolVersion uint16 + SessionID SessionID + CreatedAtUnixMilli int64 + ParentFork *ForkPoint // v1 必须为 nil;附录 A + CausationID es.CausationID + Metadata jsonstable.Value + HeaderDigest es.Digest +} + +type SessionEvent struct { + Seq Seq + CommitID CommitID // 同一次 Append 的行相同 + Index uint16 // 组内序号,从 0 递增 + Last bool // 组内最后一行 + Type EventType + RecordedAtUnixMilli int64 + SourceSeqs []Seq // 可选;语义由声明它的模块解释,kernel 不校验 + Ignorable bool // 写者声明:不认识该 Type 的 reader 可以跳过它 + Payload jsonstable.Value + Digest es.Digest // 覆盖本行全部字段与前一行的 Digest +} + +type UncommittedEvent struct { + Type EventType + RecordedAtUnixMilli int64 + SourceSeqs []Seq + Ignorable bool + Payload jsonstable.Value +} +type Group struct { + CommitID CommitID + Events []UncommittedEvent // 非空 +} +type Head struct { Next Seq; Digest es.Digest } // 空日志为 {0, HeaderDigest} +``` + +**SES-WIR-1** identity 非空、稳定、有效 UTF-8。`Seq` 从 0 连续;一次 `Append` 写入的行 `CommitID` 相同,`Index` 从 0 连续,最后一行 `Last=true`;`CommitID` 在同一 stream 内唯一。`Payload` 必须是 canonical JSON object(RFC 8785),完整字节进入 digest。 + +**SES-WIR-2** digest preimage: + +```text +HeaderDigest = Digest("twilight/session/header", ProtocolVersion, SessionID, CreatedAtUnixMilli, CausationID, Metadata) +Digest(row) = Digest("twilight/session/event", prev, SessionID, Seq, CommitID, Index, Last, Type, RecordedAtUnixMilli, SourceSeqs, Ignorable, Payload) + 其中 prev 为前一行的 Digest,Seq 0 的 prev 为 HeaderDigest +``` + +digest 依 `agent/es` 的 versioned domain separator。链条按行连接;任何行被改写、删除或重排都使其后所有行的 digest 失效。 + +**SES-WIR-3** 同一 Session 的 header 与每一行使用同一 `ProtocolVersion`;Store 从 header 派生版本,调用方不传版本。 + +## 4. 所有权 + +```go +type OpenOptions struct { + // Takeover 为假时,已有有效 Writer 的 Open 返回 ErrOwned;为真时接管:Epoch 加一, + // 旧写者被 fencing。何时允许接管是 kernel 之上的策略。 + Takeover bool +} +type Writer interface { // kernel 的写者句柄,由 Store.Open 返回 + SessionID() SessionID + Epoch() Epoch + Head() Head + Append(context.Context, Group) ([]SessionEvent, error) + Close(context.Context) error +} +type Store interface { + Create(context.Context, CreateRequest) (SessionHeader, error) + Header(context.Context, SessionID) (SessionHeader, error) + Open(context.Context, SessionID, OpenOptions) (Writer, error) + Read(context.Context, ReadRequest) (ReadPage, error) +} +``` + +**SES-OWN-1** 同一 Session 同一时刻至多一个有效 Writer。`Open` 在已有有效 Writer 且未声明 `Takeover` 时返回 `ErrOwned`;声明 `Takeover` 的 Open 接管所有权。接管的安全性由 Epoch fencing(SES-OWN-2)承担;何时允许接管(进程死亡判定、租约、人工指令)是 kernel 之上的策略,kernel 不承载 TTL 或心跳。 + +**SES-OWN-2** 每次成功的 Open 使该 Session 的 `Epoch` 加一并持久化。`Append` 携带 Writer 的 Epoch;Store 对落后于当前持久化 Epoch 的调用返回 `ErrOwnershipLost`,不写入任何内容。这是 fencing:被接管的旧写者的迟到写入不可能进入日志。 + +**SES-OWN-3** 所有权是 Session 级的,不是执行目标级的。一个进程取得 Session 的所有权即拥有其中全部执行;接管者读日志后对所有仍在执行中的目标做一次性处置(RUN-CMT-7)。kernel 不知道"执行中"是什么,这一步由 run 模块在 Writer 上完成。 + +**SES-OWN-4** `Read` 不需要所有权,任何进程可以随时读;读到的是完整组构成的前缀(SES-APP-2)。 + +## 5. append + +**SES-APP-1** `Append(group)` 原子:整组 event 同时可见或同时不存在。Store 为组内每行赋 `Seq`(从当前 `Head.Next` 起连续)、`Index`、`Last`,计算 `Digest`,持久化,然后返回带完整字段的行。返回即持久(文件 adapter 每次 Append 一次 `fsync`;数据库 adapter 一个事务)。 + +**SES-APP-2** 崩溃只可能留下一个不完整的尾组:文件 adapter 打开时把末尾 `Last=false` 且没有后续行的整组截掉;数据库 adapter 由事务保证不会出现。reader 在任何时刻都不会看到不完整的组。 + +**SES-APP-3** kernel 拒绝:空组、重复 `CommitID`、非 canonical 或非 object 的 payload、无效 identity、落后的 Epoch。拒绝不写入任何内容,返回 `ErrInvalid`(重复 CommitID 为 `ErrConflict`)。kernel 不比对重复 CommitID 的内容,不返回"已应用":幂等重放由 `extension.Writer` 以内存索引完成(EXT-WRT-2)。 + +## 6. read + +```go +type ReadRequest struct { + SessionID SessionID + From Seq // 起点,含 + Types []EventType // 空为全部;非空为 EventType 前缀过滤(优化,不改变语义) + Limit uint32 // 0 为不限 +} +type ReadPage struct { Header SessionHeader; Events []SessionEvent; Head Head; HasMore bool } +``` + +**SES-REP-1** `Read` 按 `Seq` 递增返回 `From` 起的行,只返回完整组内的行;`Limit` 截断只发生在组边界。损坏检测的义务点在 `Open`:Open 在建立所有权前校验整条 `Digest` 链,损坏必须 fail loudly(`ErrCorrupt`);`ValidateChain` 同时作为显式校验入口导出。`Read` 信任存储,不逐次重算链。 + +**SES-REP-2** `Types` 过滤是读取代价的优化:文件 adapter 全量扫描后过滤,数据库 adapter 用 `(SessionID, Type 前缀)` 索引。过滤与不过滤读到的事件集合对匹配类型完全一致。 + +## 7. errors 与 conformance + +```go +type ErrorCode string +const ( + ErrInvalid ErrorCode = "invalid"; ErrNotFound ErrorCode = "not_found" + ErrConflict ErrorCode = "conflict"; ErrCorrupt ErrorCode = "corrupt" + ErrOwned ErrorCode = "owned"; ErrOwnershipLost ErrorCode = "ownership_lost" + ErrUnsupportedProfile ErrorCode = "unsupported_profile"; ErrUnsupported ErrorCode = "unsupported" +) +``` + +v1 conformance 以 `Store` 为参数,Memory 与文件 adapter 跑同一套,必须验证: + +- **SES-WIR-1/2/3**:Seq 连续、组内 Index/Last、CommitID 唯一、payload canonical、digest 链与 header 根、版本一致; +- **SES-OWN-1/2**:第二个 Open 返回 `ErrOwned`;Close 后可再 Open 且 Epoch 加一;声明 `Takeover` 的 Open 在所有权存续期间接管且 Epoch 加一;旧 Writer 的 Append 返回 `ErrOwnershipLost` 且不写入; +- **SES-APP-1/2/3**:整组可见性;在组中途注入崩溃后打开,尾组不出现;拒绝项无写入; +- **SES-REP-1/2**:顺序、From、Limit 在组边界截断、过滤与全量对匹配类型一致、篡改任一行后下一次 Open 报 `ErrCorrupt`; +- **SES-SCP-3**:附录 A 入口返回 `ErrUnsupported`,`ParentFork` 非 nil 的 header 被拒绝。 + +参考实现为 MemoryStore 与文件 adapter `agent/session/filestore`(一个 Session 一个目录:`header.json`、`log.jsonl` 一行一个 event、`owner.json` 记录 epoch 与 owned)。 + +## 附录 A:预留能力(不进入 v1) + +**Fork。** `ForkPoint{ParentSessionID, Seq, Digest}`;子 Session 复制父的前缀作为 seed,header 记 `ParentFork`,seed 之后第一行的 prev digest 为 `ForkPoint.Digest`。目前没有规范内的消费者:subagent 使用独立 Session。 + +**Canonical import。** 按行校验 digest 链后导入完整日志或已有可验证前缀的连续尾部;同 `(SessionID, Seq)` 仅在行逐字节相同时幂等。 + +**投影缓存与 ancestry。** 有 Fork 后投影缓存的 `Through` 需要绑定 ancestry;v1 只有一个 segment,`Through` 为 `Seq`(EXT-PRJ-3)。 diff --git a/docs/design/agent-turn.md b/docs/design/agent-turn.md new file mode 100644 index 0000000..f248238 --- /dev/null +++ b/docs/design/agent-turn.md @@ -0,0 +1,297 @@ +# Twilight Agent Turn 协议 + +状态:设计草案。`agent/turn` 已按本文实现:Coordinator 的 Start / Deliver / Retry / Stop / Settle / Status(纯协议:提交与状态读取,驱动属宿主)、CompanionV1、surface 投影,写入经 `extension.Writer`、以 `Seq` 定位、恢复走接管处置。第 8 节 conformance 尚未完整实现,当前由 `agent/ref` 的测试覆盖 Start、Deliver、Stop 与新 Turn 的开启。Run 事实与 Turn、Chatlog 事件同在一条 Session stream。 + +本文定义 `agent/turn`:回合生命周期、Run attempt 的创建与结算、Run 事实到对话内容的伴随映射。"必须""应该"为协议约束。Run Machine 与 Runtime 的 authority 是 [agent-run.md](agent-run.md);对话内容的 authority 是 [agent-session-chatlog.md](agent-session-chatlog.md);stream、commit 与 projection 机制的 authority 是 [agent-session.md](agent-session.md) 与 [agent-session-extension.md](agent-session-extension.md)。 + +## 1. 模型与范围 + +```text +Turn 逻辑回合。由一组 delivered Input 触发,以 completed / failed / superseded 结束。 +Run 完成一个 Turn 的一次 attempt。同一 Turn 至多一个非终态 Run;可以有多个已终结的 Run。 +``` + +| Concern | Canonical owner | 写入者 | +|---|---|---| +| 回合存在、attempt 归属与结束 | `twilight/turn/` events | Coordinator | +| Run 执行状态 | `twilight/run/` events([agent-run.md](agent-run.md)) | `run.Runtime`,由 Loop 与 Coordinator 驱动 | +| 对话内容 | `twilight/chatlog/` events | Start 与 Deliver 时 delivered input;Run commit 内的 companion events | +| Application policy | Application | profile、driver、retry、context 策略、产品策略 | + +**TRN-SCP-1** Source 为 `twilight`,ModuleID 为 `turn`。一个 Turn 与它的全部 Run attempt 在同一 Session stream 内。`Coordinator` 创建 Turn、创建 attempt、在回合中途投递输入、驱动 Run、结算 Turn。turn 依赖 run;run 不依赖 turn,Run 事实中的 `OwnerID` 由本模块以 `TurnID` 填充。本模块的 `Requires`(EXT-REG-4)为:`run`,消费 `twilight/run/created` v1、`twilight/run/input_accepted` v1 与 `twilight/run/ended` v1;`chatlog`,只要求存在。 + +**TRN-SCP-2** Turn 与 Run 的关系为 1:N,不变量为同一 Turn 至多一个非终态 Run: + +| 动作 | 语义 | RunID | +|---|---|---| +| resume | 继续一个非终态 Run(进程重启、lease 恢复、Waiting 响应后) | 不变 | +| retry | 前一 Run 已终结且未 completed,同一 Turn 再开一个 attempt | 新 RunID,`Attempt` 加 1 | +| replace | 输入内容被替换,`twilight/turn/superseded` 指向新 Turn | 新 Turn、新 RunID | + +subagent 使用独立 Session 与独立 Turn。 + +**TRN-SCP-3** Coordinator 没有隐藏状态。它从 `twilight/turn/surface` 投影与 `twilight/run/machine` 投影重建。 + +**TRN-SCP-4** Turn 自己的写入经该 Session 的 `extension.Writer.Commit`;Run 事实的写入经 `run.Runtime`,后者经同一个 Writer 落在同一 `session.Store`(EXT-SCP-1)。Coordinator 与 Runtime 经 `extension.Writers` 取得 Writer(EXT-WRT-6)。Artifact 由其 owner 管理。 + +**TRN-SCP-5** Application 管理 model、provider、tool、prompt、token、approval、queue、retry 决策与并发。宿主按 persisted profile 解析 driver 并驱动(REF-DRV-1)。参考 Planner 每次 Plan 使用 Profile 的 `ModelRef`。 + +**TRN-SCP-6** Start 之前建立 immutable execution profile。Session 保存 `ProfileRef{ID, Digest}`。密钥与 client 留在进程内。Resolve 失败返回 `profile_unavailable`。公开字段与 digest 边界见 [参考组装](agent-reference-assembly.md)。 + +## 2. identity 与事件 + +```go +type TurnID string +type TurnRef struct { SessionID session.SessionID; TurnID TurnID } +type ProfileRef struct { ID ProfileID; Digest es.Digest } +type CompanionVersion string + +type Settlement string +const ( + SettlementCompleted Settlement = "completed" + SettlementFailed Settlement = "failed" + SettlementStopped Settlement = "stopped" +) + +type StartedPayload struct { + TurnID TurnID + InputIDs []chatlog.InputID + Profile ProfileRef + Companion CompanionVersion +} +type CompletedPayload struct { + TurnID TurnID + RunID run.RunID // 产生 completed 的 attempt +} +type FailedPayload struct { + TurnID TurnID + RunID run.RunID // 最后一个 attempt + Settlement Settlement // failed | stopped + FailureClass string +} +type SupersededPayload struct { + TurnID TurnID + ReplacementTurnID TurnID +} +``` + +**TRN-ID-1** `TurnRef`、RunID、profile ID、CompanionVersion、InputID 与 digest 非空且稳定。 + +**TRN-ID-2** `PlanDigest = Digest("twilight/turn/plan", TurnID, Profile.Digest, Companion, ordered InputIDs)`。PlanDigest 只参与 TRN-ID-3 的派生,不落盘:`started` payload 的每个字段都是它的 preimage 成员,落盘该 digest 不提供额外判定。 + +**TRN-ID-3** `StartOperationDigest = Digest("twilight/turn/start-operation", SessionID, TurnID, PlanDigest)`。用户正文 identity 在对应 `twilight/chatlog/input_submitted` 中。 + +**TRN-ID-4** attempt 的 RunID 由 Coordinator 派生:`RunID = Digest("twilight/turn/run", SessionID, TurnID, Attempt)`。Attempt 从 1 开始。`twilight/run/created` 的 `Owner` 等于 `OwnerID(TurnID)`,`Attempt` 等于该值(RUN-NEW-1)。 + +**TRN-EVT-1** EventType: + +```text +twilight/turn/started +twilight/turn/completed +twilight/turn/failed +twilight/turn/superseded +``` + +unsettled Turn 是尚未 completed、failed 或 superseded 的 `started`。 + +**TRN-EVT-2** 本模块产生的事件(含 companion 与 Attach 产生的)没有独立 EventID,`Seq` 即身份(SES-WIR-1);同一次写入的事件共用 CommitID。Start 的 CommitID 由 StartOperationDigest 派生;Retry、Settle、Stop 的 CommitID 见各自条目。同 CommitID 相同 canonical payload 为 already-applied;差异为 conflict(EXT-WRT-2)。事件时间戳不参与幂等判定。 + +**TRN-EVT-3** stream 内每个 TurnID 至多一条 `started`,至多一条 `completed` / `failed` / `superseded`。 + +**TRN-PRJ-1** ProjectionID 为 `twilight/turn/surface`。消费 `twilight/turn/started|completed|failed|superseded` 与 `twilight/run/created|input_accepted|ended`,其他事件按 EXT-PRJ-2 处理: + +```go +type TurnStatus string +const ( + TurnActive TurnStatus = "active" // 存在非终态 Run + TurnAttemptFailed TurnStatus = "attempt_failed" // 最后一个 Run 已终结且未 completed,Turn 未结算 + TurnCompleted TurnStatus = "completed" + TurnFailed TurnStatus = "failed" + TurnStopped TurnStatus = "stopped" + TurnSuperseded TurnStatus = "superseded" +) +type AttemptView struct { + RunID run.RunID + Attempt uint32 + SchemaVersion uint16 // created.SchemaVersion;Coordinator 据此构造该 attempt 的 command envelope + End *run.RunEnd // 非终态时为 nil +} +type TurnView struct { + TurnID TurnID + Status TurnStatus + InputIDs []chatlog.InputID // started 的初始输入,加此后经 Deliver 进入任一 attempt 的输入,按 accepted 顺序去重 + Profile ProfileRef + Attempts []AttemptView // 按 Attempt 递增 + ActiveRun run.RunID // Status=active 时非空 + ReplacementTurnID TurnID +} +type TurnSurface struct { + Order []TurnID + Turns map[TurnID]TurnView +} +``` + +UI 按 `TurnID` 连接 `twilight/chatlog/surface` 的条目,按 `RunID` 连接 `twilight/run/machine` 的实时视图。终态 attempt 的结果从 `twilight/run/ended` 记录在 `AttemptView.End`,不依赖 Run 投影。 + +## 3. API + +```go +type Coordinator struct { + Writers extension.Writers // 每个方法按 Ref.SessionID 取 Writer:写入经 Commit,读取经 Projections() + Runtime run.Runtime +} + +// Service 只做协议提交与状态读取;驱动 Run 属宿主(REF-DRV)。 +// 每个方法在提交落盘后立即返回,响应反映已提交的状态。 +type Service interface { + Start(context.Context, StartRequest) (TurnResponse, error) + Deliver(context.Context, DeliverRequest) (TurnResponse, error) + Retry(context.Context, RetryRequest) (TurnResponse, error) + Stop(context.Context, StopRequest) (TurnResponse, error) + Settle(context.Context, SettleRequest) (TurnResponse, error) + Status(context.Context, TurnRef) (TurnResponse, error) +} +type StartRequest struct { + Ref TurnRef + Inputs []run.AgentInput // ID 为已 submitted 的 InputID,Payload 等于其 Content + Profile ProfileRef + Companion CompanionVersion +} +type DeliverRequest struct { Ref TurnRef; Inputs []run.AgentInput } // 回合中途追加输入 +type RetryRequest struct { Ref TurnRef; Reason string } +type StopRequest struct { Ref TurnRef; Reason string } +type SettleRequest struct { Ref TurnRef; FailureClass string } +type TurnResponse struct { + Ref TurnRef + RunID run.RunID + Attempt uint32 + Status TurnStatus + Disposition ResumeDisposition + End *run.RunEnd // 该 attempt 已终结时非空,来自 twilight/run/ended + Waiting []run.ResponseRequest +} +type ResumeDisposition string +const ( + ResumeWaitingForResponse ResumeDisposition = "waiting_for_response" + ResumeWaitingForRecovery ResumeDisposition = "waiting_for_recovery" + ResumeFinished ResumeDisposition = "finished" +) +``` + +宿主在该词汇表上扩展 `already_driving`(`ref.ResumeAlreadyDriving`,REF-DRV-1):输入已提交、同 Run 的另一个本地驱动者继续推进。Coordinator 本身不产生该值。 + +**TRN-API-1** Coordinator 经 Writer 的 `Projections()` 读取 `twilight/turn/surface` 与 `twilight/run/machine` 两个投影(EXT-PRJ-4);每个方法先读投影再决定动作。Coordinator 不持有 `session.Store`。 + +**TRN-API-2** Run 的写入只经 `run.Runtime`。driver 的组装与解析在宿主(REF-BND-2)。 + +**TRN-API-3** DTO 为值语义。`Waiting` 为 `twilight/run/machine` 的 `WaitingCalls`。`NeedsRecovery` 为 true 时返回 `ResumeWaitingForRecovery`;这只出现在接管处置之前,宿主调用 `Runtime.RecoverInterrupted`(RUN-CMT-7)后再驱动(REF-DRV-1)。 + +**TRN-API-4** `twilight/turn/superseded` 由 Application 追加。Coordinator 的方法不写该事件。superseded 的 Turn 若仍有非终态 Run,Application 必须先 Stop。 + +## 4. Start 与 Retry + +**TRN-STR-1** StartRequest: + +1. Ref、profile ref、companion version 非空; +2. `Inputs` 无重复 ID;每个 ID 对应 chatlog 中状态为 submitted 的 Input,Payload 等于其 Content(Coordinator 经 chatlog surface 投影核对)。 + +`started.InputIDs` 与 `input_delivered`、`input_accepted` 的顺序都取 `Inputs` 的顺序。 + +**TRN-STR-2** Start 是一次原子 commit,顺序为: + +```text +twilight/turn/started{TurnID, InputIDs, Profile, Companion} +twilight/chatlog/input_delivered{InputIDs[0], TurnID} +... +twilight/chatlog/input_delivered{InputIDs[n-1], TurnID} +twilight/run/created{RunID, Owner:TurnID, Attempt:1, SchemaVersion, CausationID} +twilight/run/input_accepted{RunID, InputIDs[0], Payload} +... +twilight/run/input_accepted{RunID, InputIDs[n-1], Payload} +``` + +InputIDs 为空时 group 为 `started` 加 `created`。`created` 与 `input_accepted` 的 facts 由 `run.Protocol.BuildCreateGroup` 构造(RUN-NEW-1),Coordinator 只负责把它们放入 group。 + +**TRN-STR-3** 派生 PlanDigest、StartOperationDigest、RunID 与 group identity,再经 `Writer.Commit` 写入一组。相同 identity 为 applied / already-applied;Writer 串行执行全部写入,不存在 head conflict。 + +**TRN-STR-4** append 成功后 Start 返回已提交状态的响应;驱动新 Run 是宿主的下一步(REF-DRV-1)。 + +**TRN-RTY-1** Retry 要求投影中该 Turn 为 `attempt_failed`。commit 为 `twilight/run/created{Attempt: n+1}` 加该 Turn 已 delivered 的全部 Input 的 `input_accepted`,顺序与 `TurnView.InputIDs` 相同(初始输入在前,中途 Deliver 的输入按 accepted 顺序在后);payload 与首次 delivered 时相同,仅 RunID 与 Attempt 不同。Turn 为其他状态时 Retry 返回 conflict。 + +**TRN-RTY-2** Retry 的 CommitID 由 `Digest("twilight/turn/retry", SessionID, TurnID, Attempt)` 派生。 + +**TRN-RTY-3** 失败 attempt 已提交的 assistant 与 tool_result 保留在 stream 中,协议不删除、不隐藏。它们是否进入后续 attempt 的模型请求是 Application 策略,由 Planner 依据 turn surface 的 attempt 状态决定(REF-PLN-6);协议只保证内容可用。 + +## 5. Deliver、Status 与 Stop + +**TRN-DLV-1** Deliver 在回合中途追加输入,要求 Turn 为 `active`;`attempt_failed`、已结算或不存在的 Turn 返回 conflict,输入保持 `submitted`,由 Application 决定开新 Turn。输入的校验与 TRN-STR-1 第 2 条相同。 + +**TRN-DLV-2** 对 `Inputs` 中每个输入按顺序提交一个 Run commit:`Runtime.Commit(AcceptInput{Input})`,`Attach` 携带 `twilight/chatlog/input_delivered{InputID, TurnID}`。envelope 的 SchemaVersion 取自 turn surface 中该 attempt 的 `SchemaVersion`,`Base` 为零值(`AcceptInput` 不做 hard CAS,RUN-CMT-4);Deliver 不读取 `twilight/run/machine` 投影。Run 接受输入与 chatlog 把输入挂到 Turn 在同一 commit 可见。`AcceptInput` 在 Run 的任意非终态都被接受(RUN-MCH-4),Deliver 不关心 Run 当前处于哪一步。CommandID 为 Run 的 input CommandID,重放幂等;多条输入中途失败时,以剩余条目重试。 + +**TRN-DLV-3** Deliver 不取消正在进行的模型调用或工具调用;要打断用 Stop。提交后 Deliver 返回;是否驱动由宿主决定(REF-DRV-1),已在驱动时运行中的 Loop 在下一次 Load 看到 `PendingInputs`。Deliver 与该 Run 的最后一步 `SubmitModelResult` 并发时由 Writer 串行定序:输入先提交,Run 回到 `Open` 继续;结果先提交,Run 已终结,Deliver 得到 `ErrRunTerminal` 并返回 `completed`,该输入未被 delivered。 + +**TRN-STA-1** Status 是纯读取,disposition 判定的单一来源:读投影设置 `Disposition` 与 `End`。Run 终态为 `ResumeFinished`,`End` 取 surface 中该 attempt 的 `AttemptView.End`;`NeedsRecovery` 为 true 为 `ResumeWaitingForRecovery`;仅有 WaitingCalls 为 `ResumeWaitingForResponse`。宿主驱动结束后调用 Status 组装结果(REF-DRV-1);Start/Deliver/Retry/Stop/Settle 的响应用同一判定。 + +**TRN-STA-2** EventSink 的 `text_delta` / `reasoning_delta` 为临时观察。Waiting 由 Application 提交 `ApproveToolCall` / `RejectToolCall` / `SubmitToolResponse` 后再次驱动(REF-DRV-1)。 + +**TRN-STP-1** Stop 要求 Turn 为 `active`。Coordinator 提交 `CancelRun{Reason:ReasonCancelled}`,并在 `CommitRequest.Attach` 中附加 `twilight/turn/failed{Settlement:stopped, FailureClass:"cancelled"}`;两者在同一 commit 可见。envelope 的 SchemaVersion 与 Deliver 同样取自 `AttemptView`,`Base` 为零值。结算 Turn 是 Turn 层的决定,由发起 Stop 的 Coordinator 声明,Run 事实与 companion 不推断它。Application 直接提交的 `CancelRun` 不附加结算事件,Turn 进入 `attempt_failed`。Stop 时仍在 `PendingInputs` 中、尚未被 Prepare 消费的输入已经 delivered 到该 Turn:随后 Retry 会把它们与其他已 delivered 输入一起重放给新 attempt;Settle 则让它们随该 Turn 一起结束,不再进入任何模型请求。 + +**TRN-STP-2** Cancel CommandID = `Digest("twilight/turn/cancel-run", SessionID, TurnID, RunID, ReasonCancelled)`。StopRequest.Reason 供审计。 + +**TRN-STL-1** Settle 要求 Turn 为 `attempt_failed`,追加 `twilight/turn/failed{Settlement:failed, FailureClass}`。CommitID 由 `Digest("twilight/turn/settle", SessionID, TurnID, RunID)` 派生。 + +## 6. companion:Run 事实到对话内容 + +Run 事实只保存执行状态与内容 digest(RUN-WIR-4)。模型文本、工具调用与工具输出以 chatlog 事件形式与产生它们的 Run 事实写在同一组(一次 `Append`,同一 CommitID)。`run.Runtime.Commit` 在 Decide 之后、写入之前调用注入的 `run.Companion`,把本组的 facts 与 command 携带的 transient 内容映射为 `run.ModuleEvent`,追加在 Run facts 之后;随后整个 group 经 `Writer` 的 codec、Binding admission 与 claim 写入(EXT-WRT-1、EXT-WRT-3)。接口定义在 `agent/run`(第 5 节);本模块提供实现 `CompanionV1`,它把 `CompanionRequest.Owner` 解释为 TurnID。 + +**TRN-CMP-1** `Map` 为确定性纯函数,不做 IO;时间取 `CompanionRequest.RecordedAtUnixMilli`。条目自身的 identity(AssistantID、ToolResultID)按 TRN-MAP-2 派生。同一 command 重放得到同一 group。companion 事件可以携带 `ReferencePart`;其 Binding 由 Writer 在 Append 之前 admission 并建立 claim(EXT-WRT-3),Runtime 不另行处理。 + +**TRN-CMP-2** v1 映射: + +| Run fact | companion event | +|---|---| +| `ModelStepCompleted` | `twilight/chatlog/assistant{TurnID, Parts: text, reasoning, tool_call*, SourceDigest}` | +| `ToolCallCompleted` / `ToolCallAnswered` | `twilight/chatlog/tool_result` status=`success` | +| `ToolCallFailed` Outcome=`Known` | `twilight/chatlog/tool_result` status=`error` | +| `ToolCallFailed` Outcome=`Unknown` 或 class=`effect_unknown` | `twilight/chatlog/tool_result` status=`unknown` | +| `RunEnded(completed)` | `twilight/turn/completed{TurnID, RunID}` | + +其余 fact 不产生 companion。`RunEnded(failed)` 与 `RunEnded(stopped)` 都不由 companion 结算 Turn:没有附加结算事件时 Turn 进入 `attempt_failed`,由 Retry 或 Settle 决定;Coordinator.Stop 以 `Attach` 声明 stopped 结算(TRN-STP-1)。模型无 tool call 但 Run 有 pending 输入时不产生 `RunEnded`(RUN-MCH 表),companion 只写 assistant,Turn 保持 `active`。 + +**TRN-MAP-2** `AssistantID = Digest("twilight/chatlog/assistant-id", TurnID, ModelStepID, CompanionVersion)`。`ToolResultID = Digest("twilight/chatlog/tool-result-id", TurnID, CallID, CompanionVersion)`。assistant 的 ToolCall 顺序与模型结果一致;`ToolCallPart` 携带 `CallID` 与 `ProviderCallID`。tool_result 以 CallID 与同 Turn 的 call 配对。CallID 由 Run 从 `(ModelStepID, index)` 派生,同一 Turn 内不跨 ModelStep 复用。 + +**TRN-MAP-3** assistant 正文与工具输出来自 command 携带的冻结值。`Assistant.SourceDigest` 等于 `ModelStepCompleted.ResultDigest`,`ToolResult.SourceDigest` 等于 `ToolCallCompleted.OutputDigest` 或 `ToolCallAnswered.ResponseDigest`;`ToolCallFailed` 产生的 `tool_result` 没有 fact 记录的 digest,其 `SourceDigest` 为空。chatlog 条目自身的 `Digest` 仍按 CHT-COD-3 覆盖 parts。Runtime 在写入前校验非空 `SourceDigest` 的这一等式(RUN-CMT-3 第 9 步)。 + +**TRN-MAP-4** Known 对应 `error`;Unknown 对应 `unknown`。v1 companion 不写 `tool_result_superseded`。 + +## 7. recovery + +**TRN-REC-1** 恢复扫描 `twilight/turn/surface` 中 `active` 与 `attempt_failed` 的 Turn。 + +**TRN-REC-2** + +| 情形 | 动作 | +|---|---| +| `started` 已提交、进程在驱动前退出 | 新 owner 的 `RecoverInterrupted` 无事可做(Run 在 Open);宿主 Drive | +| Loop 的 Commit 返回非 sentinel 错误 | Loop 以同一 Claim 重放一次(RUN-LOP-5);Writer 按 CommitID 幂等 | +| 模型 Executing、owner 进程崩溃 | 新 owner 的 `RecoverInterrupted` 提交 `RecoverModelExecution`(RUN-CMT-7);Run 保持 Active,同一 RunID 以同一冻结请求继续 | +| 工具 Executing、owner 进程崩溃 | 新 owner 的 `RecoverInterrupted` 提交该 call 的 Unknown,companion 写 status=`unknown`;Run 保持 Active | +| Writer 返回 `ErrOwnershipLost` | 本进程放弃该 Session 的全部 Turn 与 Loop(RUN-CMT-6);由持有新 Epoch 的进程按上两行接管 | +| Run 已 `failed`、Turn 未结算 | Turn 为 `attempt_failed`;Application 选择 Retry 或 Settle | +| Stop 的 Commit 返回非 sentinel 错误 | 以同一 Cancel CommandID 重放 | +| Deliver 中某条输入的 Commit 返回非 sentinel 错误 | 以同一 input CommandID 重放,得到 already-applied 后继续剩余条目 | +| Start 或 Retry 的 Commit 返回非 sentinel 错误 | 以同一 CommitID 重放,得到 already-applied | +| profile 缺失 | 宿主 Drive 返回 `profile_unavailable`(REF-BND-2);Turn 状态不变 | + +**TRN-REC-3** 没有跨存储的对账:Run 事实、companion 内容与 Turn 结算在同一组,`Append` 原子,要么全部可见要么全部不可见。claim 在 Append 之前建立,崩溃只可能留下孤儿 claim,由 artifact 的回收前核对释放(EXT-WRT-3、ART-RET-3)。 + +## 8. conformance + +- **TRN-SCP-1 至 TRN-SCP-6**:一 Turn 至多一个非终态 Run、Source `twilight`、ModuleID `turn`、无隐藏状态、run 不依赖 turn; +- **TRN-ID-1 至 TRN-EVT-3**:所列 EventType、`twilight/turn/plan`、`twilight/turn/run` 派生 RunID、每 Turn 至多一条结算事件、不同时间戳的重试幂等; +- **TRN-PRJ-1**:surface 状态机,`active` 与 `attempt_failed` 的判定,`AttemptView.End` 来自 `run/ended`,`InputIDs` 含 Deliver 追加的输入; +- **TRN-STR-1 至 TRN-RTY-3**:Start group 顺序与原子性、Input 状态与 Content 核对、Retry 前置条件与全部已 delivered 输入的重放、Attempt 递增、幂等 CommitID、失败 attempt 内容保留在 stream; +- **TRN-DLV-1 至 TRN-DLV-3**:Deliver 前置条件、`input_accepted` 与 `input_delivered` 同 commit、Run 在 Executing 与 Waiting 时的输入入队、与最后一步结果并发时的两种定序结果、不打断进行中的调用; +- **TRN-STA-1 至 TRN-STL-1**:Status disposition 判定、Stop 以 Attach 单 commit 结算、Application 的 Cancel 进入 `attempt_failed`、Settle 前置条件; +- **TRN-CMP-1 至 TRN-MAP-4**:companion 纯函数、v1 映射表、`SourceDigest` 等于 Run fact 记录值、companion 中的 ReferencePart 经 admission 并建立 claim、同组可见性; +- **TRN-REC-1 至 TRN-REC-3**:上表恢复情形、新 Writer 接管后 `RecoverInterrupted` 再由宿主 Drive、`ErrOwnershipLost` 后本进程放弃、无跨存储对账。 diff --git a/go.mod b/go.mod index 3e7ef98..6e58334 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/google/jsonschema-go v0.4.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/gowebpki/jcs v1.0.1 github.com/modelcontextprotocol/go-sdk v1.5.0 + modernc.org/sqlite v1.57.0 ) require ( @@ -24,9 +26,16 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 0786bf1..6c719dc 100644 --- a/go.sum +++ b/go.sum @@ -26,27 +26,83 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/provider/edge/speech/speech.go b/provider/edge/speech/speech.go index 1b1072b..9611c41 100644 --- a/provider/edge/speech/speech.go +++ b/provider/edge/speech/speech.go @@ -35,11 +35,6 @@ func New(opts ...Option) *Provider { return p } -// newWithClient creates a provider with a custom client (for testing). -func newWithClient(client *edgeWsClient) *Provider { - return &Provider{client: client} -} - // SpeechModel creates a SpeechModel bound to this provider. func (p *Provider) SpeechModel(id string) *sdk.SpeechModel { if id == "" { diff --git a/provider/edge/speech/speech_test.go b/provider/edge/speech/speech_test.go index 160cd1a..d7a2f3e 100644 --- a/provider/edge/speech/speech_test.go +++ b/provider/edge/speech/speech_test.go @@ -10,6 +10,10 @@ import ( sdk "github.com/felinics/twilight/sdk" ) +func newWithClient(client *edgeWsClient) *Provider { + return &Provider{client: client} +} + func TestProvider_DoSynthesize(t *testing.T) { t.Parallel() srv := httptest.NewServer(mockEdgeTTSHandler(t)) diff --git a/provider/openai/completions/kimi_schema.go b/provider/openai/completions/kimi_schema.go index 7e57204..279d92c 100644 --- a/provider/openai/completions/kimi_schema.go +++ b/provider/openai/completions/kimi_schema.go @@ -5,6 +5,8 @@ import ( "fmt" ) +const schemaTypeObject = "object" + // normalizeSchemaForKimi converts the supported subset of standard JSON // Schema into Moonshot-flavored JSON Schema (MFJS). It always works on a deep // copy so a provider request cannot mutate the caller's tool definition. @@ -173,7 +175,7 @@ func normalizeKimiAnyOf(schema map[string]any, anyOf []any, path string) error { } hasObjectBundle := hasAnySchemaKeyword(schema, "properties", "required", "additionalProperties") - if parentType == "object" && hasObjectBundle { + if parentType == schemaTypeObject && hasObjectBundle { return distributeKimiObjectBundle(schema, anyOf, path) } if hasObjectBundle { @@ -250,8 +252,8 @@ func distributeKimiObjectBundle(schema map[string]any, anyOf []any, path string) } if rawBranchType, exists := branch["type"]; exists { branchType, ok := rawBranchType.(string) - if !ok || branchType != "object" { - return fmt.Errorf("%s.type: %v conflicts with parent type %q", branchPath, rawBranchType, "object") + if !ok || branchType != schemaTypeObject { + return fmt.Errorf("%s.type: %v conflicts with parent type %q", branchPath, rawBranchType, schemaTypeObject) } } branchRequired, err := schemaStringArray(branch["required"], branchPath+".required") @@ -263,7 +265,7 @@ func distributeKimiObjectBundle(schema map[string]any, anyOf []any, path string) return err } - branch["type"] = "object" + branch["type"] = schemaTypeObject branch["properties"] = cloneJSONValue(properties) if hasAdditional { branch["additionalProperties"] = rawAdditional diff --git a/sdk/generate_text.go b/sdk/generate_text.go index 26cbb3c..304793d 100644 --- a/sdk/generate_text.go +++ b/sdk/generate_text.go @@ -13,15 +13,19 @@ func (c *Client) GenerateText(ctx context.Context, options ...GenerateOption) (s return result.Text, nil } -// GenerateTextResult returns the full generation result, supporting multi-step -// tool execution when MaxSteps != 0. +// GenerateTextResult is the legacy high-level text wrapper. MaxSteps == 0 +// performs one model call; MaxSteps != 0 runs the compatibility tool loop. +// New multi-step runtimes should use agent/run/loop.Loop instead of this SDK +// loop. func (c *Client) GenerateTextResult(ctx context.Context, options ...GenerateOption) (*GenerateResult, error) { cfg, prov, err := buildConfig(options) if err != nil { return nil, err } - // MaxSteps == 0: single call, no tool auto-execution. + // MaxSteps == 0: single call, no tool auto-execution. Keep the legacy + // provider path byte-compatible; new code that wants the Request/ModelResult + // boundary should call Generate or Model.Generate directly. if cfg.MaxSteps == 0 { result, err := prov.DoGenerate(ctx, cfg.Params) if err != nil { diff --git a/sdk/model_call.go b/sdk/model_call.go new file mode 100644 index 0000000..e364154 --- /dev/null +++ b/sdk/model_call.go @@ -0,0 +1,133 @@ +package sdk + +import ( + "context" + "fmt" +) + +// ModelInvoker is the Request/ModelResult boundary for one model invocation. +// Existing Provider implementations do not need to implement it; Model.Generate +// falls back to Provider.DoGenerate via adapters. +type ModelInvoker interface { + Generate(context.Context, Request) (ModelResult, error) +} + +// StreamingModelInvoker is the streaming counterpart of ModelInvoker. Existing +// providers can continue implementing DoStream. +type StreamingModelInvoker interface { + Stream(context.Context, Request) (ModelStream, error) +} + +// Generate performs exactly one provider model call using the provider-neutral +// Request boundary type and returns the single-call ModelResult. It does not +// execute tools or run the legacy multi-step loop. +// +//nolint:gocritic // hugeParam: public single-call API keeps Request as a value DTO for compatibility and copy semantics. +func Generate(ctx context.Context, model *Model, req Request) (ModelResult, error) { + return defaultClient.Generate(ctx, model, req) +} + +// Stream performs exactly one provider streaming model call using the +// provider-neutral Request boundary type. The returned ModelStream assembles +// exactly one ModelResult after Parts is consumed. +// +//nolint:gocritic // hugeParam: public single-call API keeps Request as a value DTO for compatibility and copy semantics. +func Stream(ctx context.Context, model *Model, req Request) (ModelStream, error) { + return defaultClient.Stream(ctx, model, req) +} + +// Generate performs exactly one provider model call using the provider-neutral +// Request boundary type and returns the single-call ModelResult. The supplied +// model provides the provider binding; req.Model must be empty or match +// model.ID. +// +//nolint:gocritic // hugeParam: public single-call API keeps Request as a value DTO for compatibility and copy semantics. +func (c *Client) Generate(ctx context.Context, model *Model, req Request) (ModelResult, error) { + if model == nil { + return ModelResult{}, fmt.Errorf("twilightai: model is required") + } + return model.Generate(ctx, req) +} + +// Stream performs exactly one provider streaming model call using the +// provider-neutral Request boundary type. The supplied model provides the +// provider binding; req.Model must be empty or match model.ID. +// +//nolint:gocritic // hugeParam: public single-call API keeps Request as a value DTO for compatibility and copy semantics. +func (c *Client) Stream(ctx context.Context, model *Model, req Request) (ModelStream, error) { + if model == nil { + return ModelStream{}, fmt.Errorf("twilightai: model is required") + } + return model.Stream(ctx, req) +} + +// Generate performs exactly one provider model call using the provider-neutral +// Request boundary type and returns the single-call ModelResult. It is the +// non-legacy text-generation boundary: tool execution and approval orchestration +// live outside this call. +// +//nolint:gocritic // hugeParam: public single-call API keeps Request as a value DTO for compatibility and copy semantics. +func (m *Model) Generate(ctx context.Context, req Request) (ModelResult, error) { + if m == nil { + return ModelResult{}, fmt.Errorf("twilightai: model is required") + } + if m.Provider == nil { + return ModelResult{}, fmt.Errorf("twilightai: model %q has no provider", m.ID) + } + req, err := bindRequestModel(m, &req) + if err != nil { + return ModelResult{}, err + } + if provider, ok := m.Provider.(ModelInvoker); ok { + return provider.Generate(ctx, req) + } + params, err := GenerateParamsFromRequest(m, req) + if err != nil { + return ModelResult{}, err + } + result, err := m.Provider.DoGenerate(ctx, params) + if err != nil { + return ModelResult{}, err + } + return ModelResultFromGenerateResult(result), nil +} + +// Stream performs exactly one provider streaming model call. Result must be +// called only after the Parts channel is fully consumed. +// +//nolint:gocritic // hugeParam: public single-call API keeps Request as a value DTO for compatibility and copy semantics. +func (m *Model) Stream(ctx context.Context, req Request) (ModelStream, error) { + if m == nil { + return ModelStream{}, fmt.Errorf("twilightai: model is required") + } + if m.Provider == nil { + return ModelStream{}, fmt.Errorf("twilightai: model %q has no provider", m.ID) + } + req, err := bindRequestModel(m, &req) + if err != nil { + return ModelStream{}, err + } + if provider, ok := m.Provider.(StreamingModelInvoker); ok { + return provider.Stream(ctx, req) + } + params, err := GenerateParamsFromRequest(m, req) + if err != nil { + return ModelStream{}, err + } + stream, err := m.Provider.DoStream(ctx, params) + if err != nil { + return ModelStream{}, err + } + return ModelStreamFromStreamResult(stream), nil +} + +func bindRequestModel(model *Model, req *Request) (Request, error) { + out := *req + if out.Model == "" { + out.Model = model.ID + } + if model.ID != "" && out.Model != model.ID { + return Request{}, fmt.Errorf("twilightai: request model %q does not match provider model %q", out.Model, model.ID) + } + return out, nil +} diff --git a/sdk/model_call_test.go b/sdk/model_call_test.go new file mode 100644 index 0000000..108e6c1 --- /dev/null +++ b/sdk/model_call_test.go @@ -0,0 +1,242 @@ +package sdk + +import ( + "context" + "encoding/json" + "reflect" + "testing" +) + +type boundaryProvider struct { + generate func(GenerateParams) (*GenerateResult, error) + stream func(GenerateParams) (*StreamResult, error) +} + +func (p boundaryProvider) Name() string { return "boundary" } +func (p boundaryProvider) ListModels(context.Context) ([]Model, error) { return nil, nil } +func (p boundaryProvider) Test(context.Context) *ProviderTestResult { + return &ProviderTestResult{Status: ProviderStatusOK} +} +func (p boundaryProvider) TestModel(context.Context, string) (*ModelTestResult, error) { + return &ModelTestResult{Supported: true}, nil +} +func (p boundaryProvider) DoGenerate(_ context.Context, params GenerateParams) (*GenerateResult, error) { + return p.generate(params) +} +func (p boundaryProvider) DoStream(_ context.Context, params GenerateParams) (*StreamResult, error) { + return p.stream(params) +} + +func TestModelGenerateUsesRequestBoundary(t *testing.T) { + var captured GenerateParams + provider := boundaryProvider{generate: func(params GenerateParams) (*GenerateResult, error) { + captured = params + return &GenerateResult{ + Text: "ok", + FinishReason: FinishReasonStop, + Usage: Usage{TotalTokens: 7}, + ToolCalls: []ToolCall{{ + ToolCallID: "c1", + ToolName: "lookup", + Input: map[string]any{"q": "go"}, + }}, + }, nil + }} + model := &Model{ID: "m-1", Provider: provider} + result, err := model.Generate(context.Background(), Request{ + Model: "m-1", + Messages: []Message{UserMessage("hi")}, + Tools: []ToolDefinition{{ + Name: "lookup", + Parameters: json.RawMessage(`{"type":"object"}`), + }}, + ToolChoice: ToolChoice{Mode: ToolChoiceTool, Tool: "lookup"}, + }) + if err != nil { + t.Fatal(err) + } + if captured.Model != model || len(captured.Messages) != 1 { + t.Fatalf("captured params = %+v", captured) + } + if len(captured.Tools) != 1 || captured.Tools[0].Execute != nil || captured.Tools[0].Name != "lookup" { + t.Fatalf("captured tools = %+v", captured.Tools) + } + choice, ok := captured.ToolChoice.(map[string]any) + if !ok || choice["type"] != "function" { + t.Fatalf("captured tool choice = %#v", captured.ToolChoice) + } + if result.Text != "ok" || result.Usage.TotalTokens != 7 || len(result.ToolCalls) != 1 { + t.Fatalf("model result = %+v", result) + } + + if _, err := model.Generate(context.Background(), Request{Model: "other"}); err == nil { + t.Fatal("expected model mismatch error") + } +} + +type nativeBoundaryProvider struct { + legacyGenerateCalled bool + legacyStreamCalled bool + nativeGenerateReq Request + nativeStreamReq Request +} + +func (p *nativeBoundaryProvider) Name() string { return "native-boundary" } +func (p *nativeBoundaryProvider) ListModels(context.Context) ([]Model, error) { return nil, nil } +func (p *nativeBoundaryProvider) Test(context.Context) *ProviderTestResult { + return &ProviderTestResult{Status: ProviderStatusOK} +} +func (p *nativeBoundaryProvider) TestModel(context.Context, string) (*ModelTestResult, error) { + return &ModelTestResult{Supported: true}, nil +} +func (p *nativeBoundaryProvider) DoGenerate(context.Context, GenerateParams) (*GenerateResult, error) { + p.legacyGenerateCalled = true + return &GenerateResult{}, nil +} +func (p *nativeBoundaryProvider) DoStream(context.Context, GenerateParams) (*StreamResult, error) { + p.legacyStreamCalled = true + ch := make(chan StreamPart) + close(ch) + return &StreamResult{Stream: ch}, nil +} +func (p *nativeBoundaryProvider) Generate(_ context.Context, req Request) (ModelResult, error) { + p.nativeGenerateReq = req + return ModelResult{Text: "native", FinishReason: FinishReasonStop}, nil +} +func (p *nativeBoundaryProvider) Stream(_ context.Context, req Request) (ModelStream, error) { + p.nativeStreamReq = req + ch := make(chan StreamPart) + close(ch) + return ModelStream{Parts: ch, Result: func() (*ModelResult, error) { + return &ModelResult{Text: "native-stream", FinishReason: FinishReasonStop}, nil + }}, nil +} + +func TestModelUsesNativeModelInvokerWhenAvailable(t *testing.T) { + provider := &nativeBoundaryProvider{} + model := &Model{ID: "m-1", Provider: provider} + generated, err := model.Generate(context.Background(), Request{}) + if err != nil { + t.Fatal(err) + } + if generated.Text != "native" || provider.nativeGenerateReq.Model != "m-1" || provider.legacyGenerateCalled { + t.Fatalf("native generate not used: result=%+v provider=%+v", generated, provider) + } + stream, err := model.Stream(context.Background(), Request{}) + if err != nil { + t.Fatal(err) + } + for range stream.Parts { + } + streamed, err := stream.Result() + if err != nil { + t.Fatal(err) + } + if streamed.Text != "native-stream" || provider.nativeStreamReq.Model != "m-1" || provider.legacyStreamCalled { + t.Fatalf("native stream not used: result=%+v provider=%+v", streamed, provider) + } +} + +func TestModelGenerateAndStreamEquivalent(t *testing.T) { + generateResult := &GenerateResult{ + Text: "hello", + Reasoning: "why", + ReasoningParts: []ReasoningPart{{ID: "r1", Text: "why", Format: ReasoningFormatOpenAIResponses, Model: "m-1", ProviderMetadata: map[string]any{"openai": map[string]any{"itemId": "rs_1"}}}}, + TextProviderMetadata: map[string]any{"google": map[string]any{"thoughtSignature": "txt-sig"}}, + FinishReason: FinishReasonToolCalls, + RawFinishReason: "tool_calls", + Usage: Usage{TotalTokens: 5}, + Sources: []Source{{SourceType: "url", ID: "src-1", URL: "https://example.test", ProviderMetadata: map[string]any{"p": "v"}}}, + Files: []GeneratedFile{{Data: "abc", MediaType: "text/plain"}}, + ToolCalls: []ToolCall{{ToolCallID: "c1", ToolName: "lookup", Input: map[string]any{"q": "go"}, ProviderMetadata: map[string]any{"tool": "meta"}}}, + Response: ResponseMetadata{ID: "resp-1"}, + } + provider := boundaryProvider{ + generate: func(GenerateParams) (*GenerateResult, error) { return generateResult, nil }, + stream: func(GenerateParams) (*StreamResult, error) { + ch := make(chan StreamPart, 16) + go func() { + defer close(ch) + ch <- &ReasoningStartPart{ID: "r1", Format: ReasoningFormatOpenAIResponses, Model: "m-1"} + ch <- &ReasoningDeltaPart{ID: "r1", Text: "why"} + ch <- &ReasoningEndPart{ID: "r1", ProviderMetadata: map[string]any{"openai": map[string]any{"itemId": "rs_1"}}} + ch <- &TextDeltaPart{ID: "txt", Text: "hello"} + ch <- &TextEndPart{ID: "txt", ProviderMetadata: map[string]any{"google": map[string]any{"thoughtSignature": "txt-sig"}}} + ch <- &StreamSourcePart{Source: Source{SourceType: "url", ID: "src-1", URL: "https://example.test", ProviderMetadata: map[string]any{"p": "v"}}} + ch <- &StreamFilePart{File: GeneratedFile{Data: "abc", MediaType: "text/plain"}} + ch <- &StreamToolCallPart{ToolCallID: "c1", ToolName: "lookup", Input: map[string]any{"q": "go"}, ProviderMetadata: map[string]any{"tool": "meta"}} + ch <- &FinishStepPart{FinishReason: FinishReasonToolCalls, RawFinishReason: "tool_calls", Usage: Usage{TotalTokens: 5}, Response: ResponseMetadata{ID: "resp-1"}} + ch <- &FinishPart{FinishReason: FinishReasonToolCalls, RawFinishReason: "tool_calls", TotalUsage: Usage{TotalTokens: 5}} + }() + return &StreamResult{Stream: ch}, nil + }, + } + model := &Model{ID: "m-1", Provider: provider} + generated, err := model.Generate(context.Background(), Request{Model: "m-1"}) + if err != nil { + t.Fatal(err) + } + stream, err := model.Stream(context.Background(), Request{Model: "m-1"}) + if err != nil { + t.Fatal(err) + } + for range stream.Parts { + } + streamed, err := stream.Result() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(generated, *streamed) { + t.Fatalf("Generate and Stream diverged:\n generate=%#v\n stream=%#v", generated, *streamed) + } +} + +func TestModelStreamAssemblesSingleModelResult(t *testing.T) { + provider := boundaryProvider{stream: func(params GenerateParams) (*StreamResult, error) { + if params.Model == nil || params.Model.ID != "m-1" { + t.Fatalf("params model = %+v", params.Model) + } + ch := make(chan StreamPart, 8) + go func() { + defer close(ch) + ch <- &StartPart{} + ch <- &StartStepPart{} + ch <- &ReasoningStartPart{ID: "r1", Format: ReasoningFormatOpenAIResponses, Model: "m-1"} + ch <- &ReasoningDeltaPart{ID: "r1", Text: "why"} + ch <- &ReasoningEndPart{ID: "r1", ProviderMetadata: map[string]any{"openai": map[string]any{"itemId": "rs_1"}}} + ch <- &TextDeltaPart{ID: "txt", Text: "hello"} + ch <- &StreamToolCallPart{ToolCallID: "c1", ToolName: "lookup", Input: map[string]any{"q": "go"}} + ch <- &FinishStepPart{FinishReason: FinishReasonToolCalls, Usage: Usage{TotalTokens: 5}, Response: ResponseMetadata{ID: "resp-1"}} + ch <- &FinishPart{FinishReason: FinishReasonToolCalls, TotalUsage: Usage{TotalTokens: 5}} + }() + return &StreamResult{Stream: ch}, nil + }} + model := &Model{ID: "m-1", Provider: provider} + stream, err := model.Stream(context.Background(), Request{Model: "m-1"}) + if err != nil { + t.Fatal(err) + } + var parts int + for range stream.Parts { + parts++ + } + if parts == 0 { + t.Fatal("no stream parts forwarded") + } + result, err := stream.Result() + if err != nil { + t.Fatal(err) + } + if result.Text != "hello" || result.FinishReason != FinishReasonToolCalls || result.Usage.TotalTokens != 5 { + t.Fatalf("result = %+v", result) + } + if result.Reasoning != "why" || len(result.ReasoningParts) != 1 { + t.Fatalf("reasoning = %q / %+v", result.Reasoning, result.ReasoningParts) + } + if len(result.ToolCalls) != 1 || result.ToolCalls[0].ToolName != "lookup" { + t.Fatalf("tool calls = %+v", result.ToolCalls) + } + if result.Response == nil || result.Response.ID != "resp-1" { + t.Fatalf("response = %+v", result.Response) + } +} diff --git a/sdk/model_result.go b/sdk/model_result.go new file mode 100644 index 0000000..6fcf02e --- /dev/null +++ b/sdk/model_result.go @@ -0,0 +1,31 @@ +package sdk + +// ModelResult is one complete model response: the single-call fields of the +// legacy GenerateResult with no auto tool loop, approval, or multi-step +// accumulation. Multi-step steps/messages live in the run loop or application. +type ModelResult struct { + Text string `json:"text"` + // Reasoning is the parts' text joined for display. Rebuild requests from + // ReasoningParts, which keeps the per-block opaque tokens. + Reasoning string `json:"reasoning,omitempty"` + ReasoningParts []ReasoningPart `json:"reasoningParts,omitempty"` + // TextProviderMetadata carries an opaque token bound to the answer text + // (e.g. a Google thought signature on a no-tool-call response). Unlike the + // legacy GenerateResult it serializes: ModelResult is persisted inside + // AgentEvents and must round-trip. + TextProviderMetadata map[string]any `json:"textProviderMetadata,omitempty"` + + FinishReason FinishReason `json:"finishReason"` + RawFinishReason string `json:"rawFinishReason,omitempty"` + Usage Usage `json:"usage"` + + Sources []Source `json:"sources,omitempty"` + Files []GeneratedFile `json:"files,omitempty"` + ToolCalls []ToolCall `json:"toolCalls,omitempty"` + + // Response is pointer-typed so an absent value actually omits: a + // struct-typed field with omitempty never omits, which would freeze a + // zero timestamp and provider wall-clock headers into every canonical + // fact digest. + Response *ResponseMetadata `json:"response,omitempty"` +} diff --git a/sdk/model_stream.go b/sdk/model_stream.go new file mode 100644 index 0000000..287d8cc --- /dev/null +++ b/sdk/model_stream.go @@ -0,0 +1,12 @@ +package sdk + +// ModelStream is the streaming counterpart of one model call. It yields +// realtime parts and assembles exactly one ModelResult; streaming and +// non-streaming model invocations must produce the same final result. +type ModelStream struct { + // Parts yields realtime stream parts. Closed when the stream ends. + Parts <-chan StreamPart + // Result returns the assembled ModelResult after Parts is fully consumed. + // It must not be called before the channel closes. + Result func() (*ModelResult, error) +} diff --git a/sdk/request.go b/sdk/request.go new file mode 100644 index 0000000..87a4fc4 --- /dev/null +++ b/sdk/request.go @@ -0,0 +1,77 @@ +package sdk + +import "encoding/json" + +// Request is the complete, frozen input of one model call. +// +// It is pure data at the top level: no provider client, no callbacks. The +// model is a provider-scoped string ID; provider binding happens when a +// ModelCatalog resolves a ModelInvoker. Messages, ProviderOptions and +// ResponseFormat.JSONSchema still carry open JSON shapes, so the agent +// runtime freezes a Request into its own canonical run.ModelRequest before +// digesting or persisting it; the request digest is defined there. +type Request struct { + // Model is the provider-scoped model ID (e.g. "claude-sonnet-5"). + Model string `json:"model"` + // System is the stable root instruction placed before the conversation. + System string `json:"system,omitempty"` + Messages []Message `json:"messages,omitempty"` + + Tools []ToolDefinition `json:"tools,omitempty"` + ToolChoice ToolChoice `json:"toolChoice,omitzero"` + + ResponseFormat *ResponseFormat `json:"responseFormat,omitempty"` + + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + MaxTokens *int `json:"maxTokens,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"` + PresencePenalty *float64 `json:"presencePenalty,omitempty"` + Seed *int `json:"seed,omitempty"` + ReasoningEffort *string `json:"reasoningEffort,omitempty"` + ReasoningSummary *string `json:"reasoningSummary,omitempty"` + PromptCacheKey *string `json:"promptCacheKey,omitempty"` + + // ProviderOptions carries provider-specific extensions keyed by provider + // namespace. Values must be JSON values; they participate in the digest. + ProviderOptions map[string]json.RawMessage `json:"providerOptions,omitempty"` +} + +// ToolDefinition is the provider-neutral, frozen description of one tool. +// Parameters is a resolved JSON Schema document: schema inference from Go +// structs happens before freezing, never after. +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters"` + // CacheControl participates in the digest like every other field. + CacheControl *CacheControl `json:"cacheControl,omitempty"` +} + +type ToolChoiceMode string + +const ( + ToolChoiceAuto ToolChoiceMode = "auto" + ToolChoiceNone ToolChoiceMode = "none" + ToolChoiceRequired ToolChoiceMode = "required" + ToolChoiceTool ToolChoiceMode = "tool" +) + +// ToolChoice is the closed replacement for the legacy `any` field. +type ToolChoice struct { + Mode ToolChoiceMode `json:"mode,omitempty"` + // Tool names the target tool when Mode == ToolChoiceTool. + Tool string `json:"tool,omitempty"` +} + +// BlobRef is a stable, content-addressed reference to binary content inside a +// frozen request. Byte resolution is the responsibility of whoever assembles +// the ModelInvoker; unstable references (expiring URLs) must not enter a +// frozen request. +type BlobRef struct { + // Digest is "sha256:<64 lowercase hex>" over the raw bytes. + Digest string `json:"digest"` + MediaType string `json:"mediaType"` + ByteSize int64 `json:"byteSize"` +} diff --git a/sdk/request_adapter.go b/sdk/request_adapter.go new file mode 100644 index 0000000..07354ea --- /dev/null +++ b/sdk/request_adapter.go @@ -0,0 +1,609 @@ +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" +) + +const legacyToolChoiceFunction = "function" + +// ModelStreamFromStreamResult adapts a legacy StreamResult into the single-call +// ModelStream boundary. It forwards every stream part while accumulating the +// final ModelResult; callers must consume Parts before calling Result. +func ModelStreamFromStreamResult(stream *StreamResult) ModelStream { + out := make(chan StreamPart, 64) + done := make(chan struct{}) + var result ModelResult + var streamErr error + + go func() { + defer close(done) + defer close(out) + if stream == nil { + streamErr = fmt.Errorf("twilightai: nil stream result") + return + } + var reasoning reasoningAccumulator + for part := range stream.Stream { + switch p := part.(type) { + case *TextDeltaPart: + result.Text += p.Text + case *TextEndPart: + if p.ProviderMetadata != nil { + result.TextProviderMetadata = cloneMetadataMap(p.ProviderMetadata) + } + case *ReasoningStartPart: + reasoning.openBlock(p.ID, p.Format, p.Model, cloneMetadataMap(p.ProviderMetadata)) + case *ReasoningDeltaPart: + reasoning.appendDelta(p.ID, p.Text, p.Format, p.Model, cloneMetadataMap(p.ProviderMetadata)) + case *ReasoningEndPart: + reasoning.closeBlock(p.ID, p.Format, p.Model, cloneMetadataMap(p.ProviderMetadata)) + case *StreamToolCallPart: + result.ToolCalls = append(result.ToolCalls, ToolCall{ + ToolCallID: p.ToolCallID, + ToolName: p.ToolName, + Input: cloneJSONLike(p.Input), + ProviderMetadata: cloneMetadataMap(p.ProviderMetadata), + }) + case *StreamSourcePart: + source := p.Source + source.ProviderMetadata = cloneMetadataMap(source.ProviderMetadata) + result.Sources = append(result.Sources, source) + case *StreamFilePart: + result.Files = append(result.Files, p.File) + case *FinishStepPart: + result.FinishReason = p.FinishReason + result.RawFinishReason = p.RawFinishReason + result.Usage = p.Usage + // Match the generate path (ModelResultFromGenerateResult): + // an absent metadata stays nil so streamed and generated + // ModelResults serialize identically — the agent runtime + // digests persisted results, and a non-nil pointer to a zero + // value would make the digest depend on the execution mode. + if responseMetadataZero(p.Response) { + result.Response = nil + } else { + result.Response = cloneResponseMetadataPtr(&p.Response) + } + case *FinishPart: + result.FinishReason = p.FinishReason + result.RawFinishReason = p.RawFinishReason + result.Usage = p.TotalUsage + case *ErrorPart: + if streamErr == nil { + streamErr = p.Error + } + } + out <- part + } + result.ReasoningParts = cloneReasoningParts(reasoning.result()) + result.Reasoning = ReasoningText(result.ReasoningParts) + }() + + return ModelStream{ + Parts: out, + Result: func() (*ModelResult, error) { + <-done + res := result + res.ReasoningParts = cloneReasoningParts(res.ReasoningParts) + res.TextProviderMetadata = cloneMetadataMap(res.TextProviderMetadata) + res.Sources = cloneSources(res.Sources) + res.Files = append([]GeneratedFile(nil), res.Files...) + res.ToolCalls = cloneToolCalls(res.ToolCalls) + res.Response = cloneResponseMetadataPtr(res.Response) + return &res, streamErr + }, + } +} + +// RequestFromGenerateParams projects the provider-level fields of legacy +// GenerateParams into the single-call Request boundary type. Client-side +// orchestration fields such as MaxSteps, callbacks, approvals, and tool +// Execute handlers intentionally do not appear in Request. +// +//nolint:gocritic // hugeParam: compatibility adapter preserves the legacy value-parameter API shape. +func RequestFromGenerateParams(params GenerateParams) (Request, error) { + if params.Model == nil { + return Request{}, fmt.Errorf("twilightai: request: model is required") + } + tools, err := ToolDefinitionsFromTools(params.Tools) + if err != nil { + return Request{}, err + } + choice, err := ToolChoiceFromLegacy(params.ToolChoice) + if err != nil { + return Request{}, err + } + return Request{ + Model: params.Model.ID, + System: params.System, + Messages: cloneMessages(params.Messages), + Tools: tools, + ToolChoice: choice, + ResponseFormat: cloneResponseFormat(params.ResponseFormat), + Temperature: clonePtr(params.Temperature), + TopP: clonePtr(params.TopP), + MaxTokens: clonePtr(params.MaxTokens), + StopSequences: append([]string(nil), params.StopSequences...), + FrequencyPenalty: clonePtr(params.FrequencyPenalty), + PresencePenalty: clonePtr(params.PresencePenalty), + Seed: clonePtr(params.Seed), + ReasoningEffort: clonePtr(params.ReasoningEffort), + ReasoningSummary: clonePtr(params.ReasoningSummary), + PromptCacheKey: clonePtr(params.PromptCacheKey), + }, nil +} + +// GenerateParamsFromRequest adapts a single-call Request back to legacy +// GenerateParams for providers that still implement Provider.DoGenerate and +// Provider.DoStream. The supplied model provides the provider binding that a +// Request intentionally does not persist. Returned tools contain definitions +// only; Execute and RequireApproval stay empty because provider calls only need +// schemas. +// +//nolint:gocritic // hugeParam: compatibility adapter preserves Request as the SDK value DTO boundary. +func GenerateParamsFromRequest(model *Model, req Request) (GenerateParams, error) { + if model == nil { + return GenerateParams{}, fmt.Errorf("twilightai: request: model is required") + } + if req.Model != "" && model.ID != "" && req.Model != model.ID { + return GenerateParams{}, fmt.Errorf("twilightai: request model %q does not match provider model %q", req.Model, model.ID) + } + if len(req.ProviderOptions) > 0 { + return GenerateParams{}, fmt.Errorf("twilightai: request providerOptions require a ModelInvoker provider") + } + tools := make([]Tool, len(req.Tools)) + for i, def := range req.Tools { + tool, err := ToolFromDefinition(def) + if err != nil { + return GenerateParams{}, fmt.Errorf("twilightai: request tool %q: %w", def.Name, err) + } + tools[i] = tool + } + return GenerateParams{ + Model: model, + System: req.System, + Messages: cloneMessages(req.Messages), + Tools: tools, + ToolChoice: req.ToolChoice.Legacy(), + ResponseFormat: cloneResponseFormat(req.ResponseFormat), + Temperature: clonePtr(req.Temperature), + TopP: clonePtr(req.TopP), + MaxTokens: clonePtr(req.MaxTokens), + StopSequences: append([]string(nil), req.StopSequences...), + FrequencyPenalty: clonePtr(req.FrequencyPenalty), + PresencePenalty: clonePtr(req.PresencePenalty), + Seed: clonePtr(req.Seed), + ReasoningEffort: clonePtr(req.ReasoningEffort), + ReasoningSummary: clonePtr(req.ReasoningSummary), + PromptCacheKey: clonePtr(req.PromptCacheKey), + }, nil +} + +// ToolDefinitionFromTool resolves a legacy Tool's Parameters into a detached +// JSON Schema document and drops execution-only fields. +func ToolDefinitionFromTool(tool Tool) (ToolDefinition, error) { + schema, err := resolveSchema(tool.Parameters) + if err != nil { + return ToolDefinition{}, err + } + params := json.RawMessage("null") + if schema != nil { + params, err = json.Marshal(schema) + if err != nil { + return ToolDefinition{}, fmt.Errorf("twilightai: marshal tool schema: %w", err) + } + } + return ToolDefinition{ + Name: tool.Name, + Description: tool.Description, + Parameters: append(json.RawMessage(nil), params...), + CacheControl: cloneCacheControl(tool.CacheControl), + }, nil +} + +// ToolDefinitionsFromTools converts a legacy tool list into provider-neutral +// definitions, preserving order. +func ToolDefinitionsFromTools(tools []Tool) ([]ToolDefinition, error) { + if tools == nil { + return nil, nil + } + out := make([]ToolDefinition, len(tools)) + for i, tool := range tools { + def, err := ToolDefinitionFromTool(tool) + if err != nil { + return nil, fmt.Errorf("twilightai: tool %q: %w", tool.Name, err) + } + out[i] = def + } + return out, nil +} + +// ToolFromDefinition adapts a provider-neutral definition back to a legacy +// Tool value for provider calls. The returned Tool has no Execute handler. +func ToolFromDefinition(def ToolDefinition) (Tool, error) { + var params any + if len(def.Parameters) > 0 && string(def.Parameters) != "null" { + var schema jsonschema.Schema + if err := json.Unmarshal(def.Parameters, &schema); err != nil { + return Tool{}, fmt.Errorf("unmarshal tool schema: %w", err) + } + params = &schema + } + return Tool{ + Name: def.Name, + Description: def.Description, + Parameters: params, + CacheControl: cloneCacheControl(def.CacheControl), + }, nil +} + +// ToolChoiceFromLegacy converts the legacy ToolChoice any shape into the +// closed provider-neutral ToolChoice. Supported legacy inputs are "auto", +// "none", "required", a ToolChoice value, or the OpenAI-style function map +// {"type":"function","function":{"name":"..."}}. +func ToolChoiceFromLegacy(choice any) (ToolChoice, error) { + switch v := choice.(type) { + case nil: + return ToolChoice{}, nil + case ToolChoice: + return v, nil + case string: + switch ToolChoiceMode(v) { + case "": + return ToolChoice{}, nil + case ToolChoiceAuto, ToolChoiceNone, ToolChoiceRequired: + return ToolChoice{Mode: ToolChoiceMode(v)}, nil + default: + return ToolChoice{}, fmt.Errorf("twilightai: unsupported tool choice %q", v) + } + case map[string]any: + return toolChoiceFromMap(v) + default: + // Accept JSON-shaped structs by round-tripping into the supported map + // form; this keeps the adapter additive without making ToolChoice any part + // of the new Request contract. + raw, err := json.Marshal(v) + if err != nil { + return ToolChoice{}, fmt.Errorf("twilightai: marshal tool choice %T: %w", choice, err) + } + var m map[string]any + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&m); err != nil { + return ToolChoice{}, fmt.Errorf("twilightai: unmarshal tool choice %T: %w", choice, err) + } + return toolChoiceFromMap(m) + } +} + +func toolChoiceFromMap(m map[string]any) (ToolChoice, error) { + typ, _ := m["type"].(string) + if typ != legacyToolChoiceFunction && typ != "tool" { + return ToolChoice{}, fmt.Errorf("twilightai: unsupported tool choice type %q", typ) + } + fn, _ := m[legacyToolChoiceFunction].(map[string]any) + if fn == nil { + fn, _ = m["tool"].(map[string]any) + } + name, _ := fn["name"].(string) + if name == "" { + return ToolChoice{}, fmt.Errorf("twilightai: tool choice requires function.name") + } + return ToolChoice{Mode: ToolChoiceTool, Tool: name}, nil +} + +// Legacy converts a closed ToolChoice back to the legacy any shape consumed by +// existing providers. +func (c ToolChoice) Legacy() any { + switch c.Mode { + case "": + return nil + case ToolChoiceAuto, ToolChoiceNone, ToolChoiceRequired: + return string(c.Mode) + case ToolChoiceTool: + return map[string]any{"type": legacyToolChoiceFunction, legacyToolChoiceFunction: map[string]any{"name": c.Tool}} + default: + return nil + } +} + +// ModelResultFromGenerateResult extracts the single-call fields of a legacy +// GenerateResult. Multi-step Steps/Messages, tool execution results, deferred +// approval state, and callbacks are intentionally not part of ModelResult. +func ModelResultFromGenerateResult(result *GenerateResult) ModelResult { + if result == nil { + return ModelResult{} + } + var response *ResponseMetadata + if !responseMetadataZero(result.Response) { + response = cloneResponseMetadataPtr(&result.Response) + } + return ModelResult{ + Text: result.Text, + Reasoning: result.Reasoning, + ReasoningParts: cloneReasoningParts(result.ReasoningParts), + TextProviderMetadata: cloneMetadataMap(result.TextProviderMetadata), + FinishReason: result.FinishReason, + RawFinishReason: result.RawFinishReason, + Usage: result.Usage, + Sources: cloneSources(result.Sources), + Files: append([]GeneratedFile(nil), result.Files...), + ToolCalls: cloneToolCalls(result.ToolCalls), + Response: response, + } +} + +// GenerateResultFromModelResult adapts a single-call ModelResult back to the +// legacy result shape. The multi-step fields remain empty. +// +//nolint:gocritic // hugeParam: compatibility adapter preserves ModelResult as the SDK value DTO boundary. +func GenerateResultFromModelResult(result ModelResult) *GenerateResult { + out := &GenerateResult{ + Text: result.Text, + Reasoning: result.Reasoning, + ReasoningParts: cloneReasoningParts(result.ReasoningParts), + TextProviderMetadata: cloneMetadataMap(result.TextProviderMetadata), + FinishReason: result.FinishReason, + RawFinishReason: result.RawFinishReason, + Usage: result.Usage, + Sources: cloneSources(result.Sources), + Files: append([]GeneratedFile(nil), result.Files...), + ToolCalls: cloneToolCalls(result.ToolCalls), + } + if result.Response != nil { + out.Response = *cloneResponseMetadataPtr(result.Response) + } + return out +} + +func clonePtr[T any](p *T) *T { + if p == nil { + return nil + } + v := *p + return &v +} + +func cloneCacheControl(c *CacheControl) *CacheControl { + if c == nil { + return nil + } + cc := *c + return &cc +} + +func cloneResponseFormat(f *ResponseFormat) *ResponseFormat { + if f == nil { + return nil + } + out := *f + if f.JSONSchema != nil { + out.JSONSchema = cloneSchema(f.JSONSchema) + } + return &out +} + +func cloneSchema(s *jsonschema.Schema) *jsonschema.Schema { + if s == nil { + return nil + } + raw, err := json.Marshal(s) + if err != nil { + return s + } + var out jsonschema.Schema + if err := json.Unmarshal(raw, &out); err != nil { + return s + } + return &out +} + +func cloneMessages(messages []Message) []Message { + if messages == nil { + return nil + } + out := make([]Message, len(messages)) + for i, msg := range messages { + out[i] = cloneMessage(msg) + } + return out +} + +func cloneMessage(msg Message) Message { + out := msg + out.Usage = clonePtr(msg.Usage) + if msg.Content != nil { + out.Content = make([]MessagePart, len(msg.Content)) + for i, part := range msg.Content { + out.Content[i] = cloneMessagePart(part) + } + } + return out +} + +func cloneMessagePart(part MessagePart) MessagePart { + switch p := part.(type) { + case TextPart: + p.CacheControl = cloneCacheControl(p.CacheControl) + p.ProviderMetadata = cloneMetadataMap(p.ProviderMetadata) + return p + case *TextPart: + if p == nil { + return nil + } + clone := *p + clone.CacheControl = cloneCacheControl(clone.CacheControl) + clone.ProviderMetadata = cloneMetadataMap(clone.ProviderMetadata) + return &clone + case ReasoningPart: + p.ProviderMetadata = cloneMetadataMap(p.ProviderMetadata) + return p + case *ReasoningPart: + if p == nil { + return nil + } + clone := *p + clone.ProviderMetadata = cloneMetadataMap(clone.ProviderMetadata) + return &clone + case ImagePart: + p.CacheControl = cloneCacheControl(p.CacheControl) + return p + case *ImagePart: + if p == nil { + return nil + } + clone := *p + clone.CacheControl = cloneCacheControl(clone.CacheControl) + return &clone + case FilePart: + p.CacheControl = cloneCacheControl(p.CacheControl) + return p + case *FilePart: + if p == nil { + return nil + } + clone := *p + clone.CacheControl = cloneCacheControl(clone.CacheControl) + return &clone + case ToolCallPart: + p.CacheControl = cloneCacheControl(p.CacheControl) + p.ProviderMetadata = cloneMetadataMap(p.ProviderMetadata) + p.Input = cloneJSONLike(p.Input) + return p + case *ToolCallPart: + if p == nil { + return nil + } + clone := *p + clone.CacheControl = cloneCacheControl(clone.CacheControl) + clone.ProviderMetadata = cloneMetadataMap(clone.ProviderMetadata) + clone.Input = cloneJSONLike(clone.Input) + return &clone + case ToolResultPart: + p.CacheControl = cloneCacheControl(p.CacheControl) + p.Result = cloneJSONLike(p.Result) + return p + case *ToolResultPart: + if p == nil { + return nil + } + clone := *p + clone.CacheControl = cloneCacheControl(clone.CacheControl) + clone.Result = cloneJSONLike(clone.Result) + return &clone + default: + return part + } +} + +func cloneMetadataMap(meta map[string]any) map[string]any { + if meta == nil { + return nil + } + out := make(map[string]any, len(meta)) + for k, v := range meta { + out[k] = cloneJSONLike(v) + } + return out +} + +func cloneJSONLike(v any) any { + switch x := v.(type) { + case nil: + return nil + case json.RawMessage: + return append(json.RawMessage(nil), x...) + case []byte: + return append([]byte(nil), x...) + case map[string]any: + return cloneMetadataMap(x) + case map[string]json.RawMessage: + out := make(map[string]json.RawMessage, len(x)) + for k, v := range x { + out[k] = append(json.RawMessage(nil), v...) + } + return out + case map[string]string: + out := make(map[string]string, len(x)) + for k, v := range x { + out[k] = v + } + return out + case []any: + out := make([]any, len(x)) + for i, v := range x { + out[i] = cloneJSONLike(v) + } + return out + case []json.RawMessage: + out := make([]json.RawMessage, len(x)) + for i, v := range x { + out[i] = append(json.RawMessage(nil), v...) + } + return out + default: + return v + } +} + +func cloneReasoningParts(parts []ReasoningPart) []ReasoningPart { + if parts == nil { + return nil + } + out := make([]ReasoningPart, len(parts)) + for i, part := range parts { + part.ProviderMetadata = cloneMetadataMap(part.ProviderMetadata) + out[i] = part + } + return out +} + +func cloneSources(sources []Source) []Source { + if sources == nil { + return nil + } + out := make([]Source, len(sources)) + for i, source := range sources { + source.ProviderMetadata = cloneMetadataMap(source.ProviderMetadata) + out[i] = source + } + return out +} + +func cloneToolCalls(calls []ToolCall) []ToolCall { + if calls == nil { + return nil + } + out := make([]ToolCall, len(calls)) + for i, call := range calls { + call.Input = cloneJSONLike(call.Input) + call.ProviderMetadata = cloneMetadataMap(call.ProviderMetadata) + out[i] = call + } + return out +} + +func cloneResponseMetadataPtr(meta *ResponseMetadata) *ResponseMetadata { + if meta == nil { + return nil + } + out := *meta + if !out.Timestamp.IsZero() { + out.Timestamp = out.Timestamp.Round(0).UTC() + } + if meta.Headers != nil { + out.Headers = make(map[string]string, len(meta.Headers)) + for k, v := range meta.Headers { + out.Headers[k] = v + } + } + return &out +} + +func responseMetadataZero(meta ResponseMetadata) bool { + return meta.ID == "" && meta.ModelID == "" && meta.Timestamp.IsZero() && len(meta.Headers) == 0 +} diff --git a/sdk/request_adapter_test.go b/sdk/request_adapter_test.go new file mode 100644 index 0000000..514e11f --- /dev/null +++ b/sdk/request_adapter_test.go @@ -0,0 +1,157 @@ +package sdk + +import ( + "encoding/json" + "testing" + + "github.com/google/jsonschema-go/jsonschema" +) + +func TestRequestFromGenerateParams(t *testing.T) { + temp := 0.7 + max := 128 + model := &Model{ID: "m-1"} + meta := map[string]any{"p": map[string]any{"sig": "s1"}} + params := GenerateParams{ + Model: model, + System: "sys", + Messages: []Message{{Role: MessageRoleUser, Content: []MessagePart{TextPart{Text: "hi", ProviderMetadata: meta}}}}, + Temperature: &temp, + MaxTokens: &max, + Tools: []Tool{{ + Name: "search", + Description: "Search", + Parameters: map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}}, + Execute: func(*ToolExecContext, any) (any, error) { return nil, nil }, + CacheControl: &CacheControl{Type: "ephemeral", TTL: "1h"}, + }}, + ToolChoice: map[string]any{"type": "function", "function": map[string]any{"name": "search"}}, + } + + req, err := RequestFromGenerateParams(params) + if err != nil { + t.Fatal(err) + } + if req.Model != "m-1" || req.System != "sys" { + t.Fatalf("request identity = %+v", req) + } + if req.ToolChoice.Mode != ToolChoiceTool || req.ToolChoice.Tool != "search" { + t.Fatalf("tool choice = %+v", req.ToolChoice) + } + if len(req.Tools) != 1 || req.Tools[0].Name != "search" || req.Tools[0].CacheControl.TTL != "1h" { + t.Fatalf("tools = %+v", req.Tools) + } + if len(req.Tools[0].Parameters) == 0 || string(req.Tools[0].Parameters) == "null" { + t.Fatalf("tool parameters not resolved: %s", req.Tools[0].Parameters) + } + + // The adapter snapshots common mutable containers instead of returning the + // original backing arrays/maps. + params.Messages[0].Content[0].(TextPart).ProviderMetadata["p"] = "mutated" + gotMeta := req.Messages[0].Content[0].(TextPart).ProviderMetadata["p"].(map[string]any) + if gotMeta["sig"] != "s1" { + t.Fatalf("request metadata aliased legacy params: %#v", gotMeta) + } +} + +func TestGenerateParamsFromRequest(t *testing.T) { + topP := 0.5 + req := Request{ + Model: "m-1", + Messages: []Message{UserMessage("hi")}, + Tools: []ToolDefinition{{ + Name: "lookup", + Description: "Lookup", + Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`), + CacheControl: &CacheControl{Type: "ephemeral"}, + }}, + ToolChoice: ToolChoice{Mode: ToolChoiceTool, Tool: "lookup"}, + TopP: &topP, + } + model := &Model{ID: "m-1"} + params, err := GenerateParamsFromRequest(model, req) + if err != nil { + t.Fatal(err) + } + if params.Model != model || params.TopP == nil || *params.TopP != topP { + t.Fatalf("params = %+v", params) + } + if len(params.Tools) != 1 || params.Tools[0].Execute != nil || params.Tools[0].RequireApproval { + t.Fatalf("tool should contain definition only: %+v", params.Tools) + } + if _, ok := params.Tools[0].Parameters.(*jsonschema.Schema); !ok { + t.Fatalf("tool parameters type = %T", params.Tools[0].Parameters) + } + choice, ok := params.ToolChoice.(map[string]any) + if !ok || choice["type"] != "function" { + t.Fatalf("legacy tool choice = %#v", params.ToolChoice) + } + + if _, err := GenerateParamsFromRequest(&Model{ID: "other"}, req); err == nil { + t.Fatal("expected model mismatch error") + } + + req.ProviderOptions = map[string]json.RawMessage{"openai": json.RawMessage(`{"reasoning":{"effort":"low"}}`)} + if _, err := GenerateParamsFromRequest(model, req); err == nil { + t.Fatal("expected providerOptions to reject legacy adapter fallback") + } +} + +func TestToolChoiceFromLegacy(t *testing.T) { + for _, mode := range []string{"auto", "none", "required"} { + choice, err := ToolChoiceFromLegacy(mode) + if err != nil { + t.Fatalf("%s: %v", mode, err) + } + if choice.Mode != ToolChoiceMode(mode) || choice.Legacy() != mode { + t.Fatalf("choice %s round trip = %+v / %#v", mode, choice, choice.Legacy()) + } + } + choice, err := ToolChoiceFromLegacy(map[string]any{"type": "function", "function": map[string]any{"name": "search"}}) + if err != nil { + t.Fatal(err) + } + if choice.Mode != ToolChoiceTool || choice.Tool != "search" { + t.Fatalf("tool choice = %+v", choice) + } + if _, err := ToolChoiceFromLegacy("bad"); err == nil { + t.Fatal("expected unsupported string tool choice to fail") + } +} + +func TestModelResultAdapters(t *testing.T) { + response := ResponseMetadata{ID: "resp-1", Headers: map[string]string{"h": "v"}} + gen := &GenerateResult{ + Text: "ok", + Reasoning: "why", + ReasoningParts: []ReasoningPart{{ID: "r1", Text: "why", ProviderMetadata: map[string]any{"p": "v"}}}, + TextProviderMetadata: map[string]any{"t": "sig"}, + FinishReason: FinishReasonStop, + Usage: Usage{TotalTokens: 3}, + Sources: []Source{{ID: "src", URL: "https://example.test", ProviderMetadata: map[string]any{"s": "m"}}}, + ToolCalls: []ToolCall{{ToolCallID: "c1", ToolName: "search", Input: map[string]any{"q": "go"}}}, + Response: response, + ToolResults: []ToolResult{{ToolCallID: "c1"}}, + Steps: []StepResult{{Text: "step"}}, + Messages: []Message{AssistantMessage("step")}, + } + + model := ModelResultFromGenerateResult(gen) + if model.Text != "ok" || model.Response == nil || model.Response.Headers["h"] != "v" { + t.Fatalf("model result = %+v", model) + } + if len(model.ToolCalls) != 1 || len(model.Sources) != 1 || len(model.ReasoningParts) != 1 { + t.Fatalf("missing single-call fields: %+v", model) + } + + // Multi-step and tool execution fields intentionally do not round-trip + // through ModelResult. + back := GenerateResultFromModelResult(model) + if len(back.ToolResults) != 0 || len(back.Steps) != 0 || len(back.Messages) != 0 { + t.Fatalf("unexpected orchestration fields: %+v", back) + } + model.TextProviderMetadata["t"] = "mutated" + if back.TextProviderMetadata["t"] != "sig" { + t.Fatal("GenerateResult aliased ModelResult metadata") + } +} diff --git a/sdk/step_helpers.go b/sdk/step_helpers.go index 91e34b4..dec5204 100644 --- a/sdk/step_helpers.go +++ b/sdk/step_helpers.go @@ -34,20 +34,7 @@ func shouldContinueLoop(maxSteps, step int) bool { } func addUsage(total, step *Usage) Usage { - result := *total - result.InputTokens += step.InputTokens - result.OutputTokens += step.OutputTokens - result.TotalTokens += step.TotalTokens - result.ReasoningTokens += step.ReasoningTokens - result.CachedInputTokens += step.CachedInputTokens - result.InputTokenDetails.NoCacheTokens += step.InputTokenDetails.NoCacheTokens - result.InputTokenDetails.CacheReadTokens += step.InputTokenDetails.CacheReadTokens - result.InputTokenDetails.CacheWriteTokens += step.InputTokenDetails.CacheWriteTokens - result.InputTokenDetails.CacheWrite5mTokens += step.InputTokenDetails.CacheWrite5mTokens - result.InputTokenDetails.CacheWrite1hTokens += step.InputTokenDetails.CacheWrite1hTokens - result.OutputTokenDetails.TextTokens += step.OutputTokenDetails.TextTokens - result.OutputTokenDetails.ReasoningTokens += step.OutputTokenDetails.ReasoningTokens - return result + return total.Add(*step) } // buildStepMessages creates the messages produced by a step: an assistant diff --git a/sdk/stream_text.go b/sdk/stream_text.go index f2bd5d9..cde83d5 100644 --- a/sdk/stream_text.go +++ b/sdk/stream_text.go @@ -6,9 +6,11 @@ import ( "fmt" ) -// StreamText returns a streaming result. When MaxSteps != 0 and tools have -// Execute handlers, the client orchestrates a multi-step loop, forwarding all -// stream parts (including ToolProgressPart) through a single channel. +// StreamText is the legacy high-level streaming text wrapper. When MaxSteps != +// 0 and tools have Execute handlers, it runs the compatibility multi-step loop, +// forwarding all stream parts (including ToolProgressPart) through a single +// channel. New multi-step runtimes should use agent/run/loop.Loop instead of +// this SDK loop. // // StreamResult.Steps and StreamResult.Messages are populated during stream // consumption and safe to read after Stream is fully consumed. diff --git a/sdk/usage.go b/sdk/usage.go index 7a33b6f..82b1fa8 100644 --- a/sdk/usage.go +++ b/sdk/usage.go @@ -26,3 +26,22 @@ type Usage struct { InputTokenDetails InputTokenDetail `json:"inputTokenDetails,omitempty"` OutputTokenDetails OutputTokenDetail `json:"outputTokenDetails,omitempty"` } + +// Add returns the field-by-field sum of u and other. +// +//nolint:gocritic // hugeParam: Add is a pure value operation and must not mutate caller-owned Usage. +func (u Usage) Add(other Usage) Usage { + u.InputTokens += other.InputTokens + u.OutputTokens += other.OutputTokens + u.TotalTokens += other.TotalTokens + u.ReasoningTokens += other.ReasoningTokens + u.CachedInputTokens += other.CachedInputTokens + u.InputTokenDetails.NoCacheTokens += other.InputTokenDetails.NoCacheTokens + u.InputTokenDetails.CacheReadTokens += other.InputTokenDetails.CacheReadTokens + u.InputTokenDetails.CacheWriteTokens += other.InputTokenDetails.CacheWriteTokens + u.InputTokenDetails.CacheWrite5mTokens += other.InputTokenDetails.CacheWrite5mTokens + u.InputTokenDetails.CacheWrite1hTokens += other.InputTokenDetails.CacheWrite1hTokens + u.OutputTokenDetails.TextTokens += other.OutputTokenDetails.TextTokens + u.OutputTokenDetails.ReasoningTokens += other.OutputTokenDetails.ReasoningTokens + return u +}